SettingsController.php 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Controllers;
  4. use App\ApiClient\AdminClient;
  5. use App\ApiClient\ApiAuthException;
  6. use App\ApiClient\ApiException;
  7. use App\Auth\SessionManager;
  8. use Psr\Http\Message\ResponseInterface;
  9. use Psr\Http\Message\ServerRequestInterface;
  10. use Slim\Views\Twig;
  11. /**
  12. * `/app/settings` — admin-only effective config + jobs status.
  13. *
  14. * Three sections render: Configuration (from `/admin/config`), Jobs
  15. * (from `/admin/jobs/status`, with overdue badges + manual-trigger
  16. * buttons), GeoIP (parsed out of the config payload).
  17. *
  18. * The trigger handler is a server-rendered POST → 303 → re-render
  19. * pattern: forwards to the api's `POST /admin/jobs/trigger/{name}` and
  20. * stashes the outcome in flash. We deliberately avoid an XHR + spinner
  21. * to keep the no-JS path working; the admin can wait the few seconds
  22. * a job takes.
  23. *
  24. * RBAC: Viewer/Operator get a no-access page; Admin only.
  25. */
  26. final class SettingsController
  27. {
  28. use CrudControllerSupport;
  29. public function __construct(
  30. private readonly Twig $twigEngine,
  31. private readonly SessionManager $sessionManager,
  32. private readonly AdminClient $admin,
  33. ) {
  34. }
  35. protected function twig(): Twig
  36. {
  37. return $this->twigEngine;
  38. }
  39. protected function sessions(): SessionManager
  40. {
  41. return $this->sessionManager;
  42. }
  43. public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  44. {
  45. if (($redirect = $this->requireUser($request, $response)) !== null) {
  46. return $redirect;
  47. }
  48. $user = $this->sessions()->getUser();
  49. if ($user === null) {
  50. return $response->withStatus(302)->withHeader('Location', '/login');
  51. }
  52. if (!$this->userIs($user, 'admin')) {
  53. return $response->withStatus(303)->withHeader('Location', '/no-access');
  54. }
  55. $config = null;
  56. $jobs = null;
  57. $error = null;
  58. try {
  59. $config = $this->admin->getConfig($user->userId);
  60. $jobs = $this->admin->getJobsStatus($user->userId);
  61. } catch (ApiAuthException) {
  62. return $response->withStatus(303)->withHeader('Location', '/no-access');
  63. } catch (ApiException $e) {
  64. $error = 'API error: ' . $e->getMessage();
  65. }
  66. return $this->twigEngine->render($response, 'pages/settings/index.twig', [
  67. 'active_section' => 'settings',
  68. 'config' => $config,
  69. 'jobs' => $jobs,
  70. 'error' => $error,
  71. ]);
  72. }
  73. /**
  74. * @param array{name: string} $args
  75. */
  76. public function trigger(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
  77. {
  78. if (($redirect = $this->requireUser($request, $response)) !== null) {
  79. return $redirect;
  80. }
  81. $user = $this->sessions()->getUser();
  82. if ($user === null) {
  83. return $response->withStatus(302)->withHeader('Location', '/login');
  84. }
  85. if (!$this->userIs($user, 'admin')) {
  86. return $response->withStatus(303)->withHeader('Location', '/no-access');
  87. }
  88. $name = $args['name'];
  89. $body = $this->formBody($request);
  90. $params = [];
  91. if (isset($body['full'])) {
  92. $params['full'] = $this->formBool($body['full']);
  93. }
  94. if (isset($body['reenrich'])) {
  95. $params['reenrich'] = $this->formBool($body['reenrich']);
  96. }
  97. try {
  98. $result = $this->admin->triggerJob($user->userId, $name, $params);
  99. $status = (string) ($result['status'] ?? 'unknown');
  100. $items = (int) ($result['items_processed'] ?? 0);
  101. $duration = (int) ($result['duration_ms'] ?? 0);
  102. $this->sessions()->flash(
  103. $status === 'success' ? 'success' : 'error',
  104. sprintf('%s — %s (items=%d, duration=%dms)', $name, $status, $items, $duration),
  105. );
  106. } catch (ApiException $e) {
  107. $this->flashFromException($e);
  108. }
  109. return $response->withStatus(303)->withHeader('Location', '/app/settings');
  110. }
  111. /**
  112. * Wipe operational data. The API requires the literal `PURGE` string;
  113. * we additionally require the user to have typed it in the form, both
  114. * to avoid drive-by clicks and to keep curl-style misuse loud.
  115. */
  116. public function purge(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  117. {
  118. if (($redirect = $this->requireUser($request, $response)) !== null) {
  119. return $redirect;
  120. }
  121. $user = $this->sessions()->getUser();
  122. if ($user === null) {
  123. return $response->withStatus(302)->withHeader('Location', '/login');
  124. }
  125. if (!$this->userIs($user, 'admin')) {
  126. return $response->withStatus(303)->withHeader('Location', '/no-access');
  127. }
  128. $body = $this->formBody($request);
  129. $confirm = isset($body['confirm']) && is_string($body['confirm']) ? trim($body['confirm']) : '';
  130. if ($confirm !== 'PURGE') {
  131. $this->sessions()->flash('error', 'Type PURGE exactly to confirm the wipe.');
  132. return $response->withStatus(303)->withHeader('Location', '/app/settings');
  133. }
  134. try {
  135. $result = $this->admin->purgeData($user->userId);
  136. $deleted = is_array($result['deleted'] ?? null) ? $result['deleted'] : [];
  137. $total = array_sum(array_map('intval', $deleted));
  138. $this->sessions()->flash('success', sprintf(
  139. 'Database purged. %d rows deleted across %d tables.',
  140. $total,
  141. count($deleted),
  142. ));
  143. } catch (ApiException $e) {
  144. $this->flashFromException($e);
  145. }
  146. return $response->withStatus(303)->withHeader('Location', '/app/settings');
  147. }
  148. /**
  149. * Load the demo dataset. Idempotent on the api side — repeats return
  150. * 409 which we surface as an info-level flash, not an error.
  151. */
  152. public function seedDemo(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  153. {
  154. if (($redirect = $this->requireUser($request, $response)) !== null) {
  155. return $redirect;
  156. }
  157. $user = $this->sessions()->getUser();
  158. if ($user === null) {
  159. return $response->withStatus(302)->withHeader('Location', '/login');
  160. }
  161. if (!$this->userIs($user, 'admin')) {
  162. return $response->withStatus(303)->withHeader('Location', '/no-access');
  163. }
  164. try {
  165. $result = $this->admin->seedDemo($user->userId);
  166. $summary = is_array($result['summary'] ?? null) ? $result['summary'] : [];
  167. $this->sessions()->flash('success', sprintf(
  168. 'Demo data loaded — %d reporters, %d consumers, %d IPs, %d reports.',
  169. (int) ($summary['reporters'] ?? 0),
  170. (int) ($summary['consumers'] ?? 0),
  171. (int) ($summary['ips'] ?? 0),
  172. (int) ($summary['reports'] ?? 0),
  173. ));
  174. } catch (ApiException $e) {
  175. $this->flashFromException($e);
  176. }
  177. return $response->withStatus(303)->withHeader('Location', '/app/settings');
  178. }
  179. }