JobsAdminController.php 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Application\Admin;
  4. use App\Application\Jobs\RefreshGeoipJob;
  5. use App\Domain\Audit\AuditAction;
  6. use App\Domain\Audit\AuditEmitter;
  7. use App\Domain\Time\Clock;
  8. use App\Infrastructure\Enrichment\Downloaders\GeoIpDownloader;
  9. use App\Infrastructure\Jobs\JobLockRepository;
  10. use App\Infrastructure\Jobs\JobRegistry;
  11. use App\Infrastructure\Jobs\JobRunner;
  12. use App\Infrastructure\Jobs\JobRunRepository;
  13. use Psr\Http\Message\ResponseInterface;
  14. use Psr\Http\Message\ServerRequestInterface;
  15. /**
  16. * Admin-side proxy for the internal jobs surface.
  17. *
  18. * - `GET /api/v1/admin/jobs/status` (Viewer): mirrors the data from
  19. * `/internal/jobs/status` but is reachable without the internal
  20. * token, and never short-circuits 404/401 on RFC1918 boundaries.
  21. * - `POST /api/v1/admin/jobs/trigger/{name}` (Admin): invokes the
  22. * same Job class the internal handler does, but with
  23. * `triggered_by="manual"`. Emits one `job.triggered` audit row.
  24. * refresh-geoip honours the same 412-no-credential short-circuit
  25. * the internal handler implements.
  26. *
  27. * The split exists so the UI never has to forward through the internal
  28. * token (which is bound to RFC1918 networks). Per SPEC §6/§7.
  29. */
  30. final class JobsAdminController
  31. {
  32. use AdminControllerSupport;
  33. public function __construct(
  34. private readonly JobRegistry $registry,
  35. private readonly JobRunner $runner,
  36. private readonly JobRunRepository $runs,
  37. private readonly JobLockRepository $locks,
  38. private readonly Clock $clock,
  39. private readonly GeoIpDownloader $geoipDownloader,
  40. private readonly AuditEmitter $audit,
  41. ) {
  42. }
  43. public function status(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  44. {
  45. $now = $this->clock->now();
  46. $latest = $this->runs->latestPerJob();
  47. $jobs = [];
  48. foreach ($this->registry->all() as $name => $job) {
  49. $row = $latest[$name] ?? null;
  50. $finishedAt = $row['finished_at'] ?? null;
  51. $overdue = $row === null
  52. || ($finishedAt instanceof \DateTimeImmutable
  53. && ($now->getTimestamp() - $finishedAt->getTimestamp()) > $job->defaultIntervalSeconds());
  54. $jobs[$name] = [
  55. 'name' => $name,
  56. 'default_interval_seconds' => $job->defaultIntervalSeconds(),
  57. 'max_runtime_seconds' => $job->maxRuntimeSeconds(),
  58. 'overdue' => $overdue,
  59. 'lock' => $this->locks->status($name),
  60. 'last_run' => $row === null ? null : [
  61. 'id' => $row['id'],
  62. 'status' => $row['status'],
  63. 'items_processed' => $row['items_processed'],
  64. 'triggered_by' => $row['triggered_by'],
  65. 'started_at' => self::formatTs($row['started_at']),
  66. 'finished_at' => self::formatTs($row['finished_at']),
  67. 'error_message' => $row['error_message'],
  68. ],
  69. ];
  70. }
  71. return self::json($response, 200, [
  72. 'now' => $now->format('Y-m-d\TH:i:s\Z'),
  73. 'jobs' => $jobs,
  74. ]);
  75. }
  76. /**
  77. * @param array{name: string} $args
  78. */
  79. public function trigger(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
  80. {
  81. $name = $args['name'];
  82. if (!$this->registry->has($name)) {
  83. return self::error($response, 404, 'unknown_job');
  84. }
  85. // refresh-geoip's credential short-circuit: same 412 envelope the
  86. // internal handler returns. Don't take the lock or even start the
  87. // job for opt-in providers without their key.
  88. if ($name === RefreshGeoipJob::NAME
  89. && $this->geoipDownloader->requiresCredential()
  90. && !$this->geoipDownloader->hasCredential()) {
  91. $missing = match ($this->geoipDownloader->name()) {
  92. 'maxmind' => 'MAXMIND_LICENSE_KEY',
  93. 'ipinfo' => 'IPINFO_TOKEN',
  94. default => 'CREDENTIAL',
  95. };
  96. return self::json($response, 412, [
  97. 'error' => 'no_credential',
  98. 'provider' => $this->geoipDownloader->name(),
  99. 'missing' => $missing,
  100. ]);
  101. }
  102. $body = self::jsonBody($request);
  103. $params = self::sanitiseParams($body);
  104. // Audit BEFORE running the job — even if the job fails, we want a
  105. // record that it was invoked. SEC_REVIEW F4: emitOrThrow so a
  106. // failed audit insert produces a 500 instead of silently running
  107. // the job without a trigger row in audit_log.
  108. $this->audit->emitOrThrow(
  109. AuditAction::JOB_TRIGGERED,
  110. 'job',
  111. $name,
  112. ['name' => $name, 'params' => $params, 'triggered_by' => 'manual'],
  113. self::auditContext($request),
  114. $name,
  115. );
  116. $job = $this->registry->get($name);
  117. $outcome = $this->runner->run($job, $params, 'manual');
  118. $response = $response
  119. ->withStatus($outcome->httpStatus())
  120. ->withHeader('Content-Type', 'application/json');
  121. $response->getBody()->write((string) json_encode($outcome->toArray()));
  122. return $response;
  123. }
  124. /**
  125. * @param array<string, mixed> $body
  126. * @return array<string, mixed>
  127. */
  128. private static function sanitiseParams(array $body): array
  129. {
  130. // Whitelist the same params the internal handler accepts; ignore
  131. // anything else so a malicious admin can't smuggle config.
  132. $params = [];
  133. if (isset($body['full'])) {
  134. $params['full'] = (bool) $body['full'];
  135. }
  136. if (isset($body['max_rows']) && is_numeric($body['max_rows'])) {
  137. $params['max_rows'] = (int) $body['max_rows'];
  138. }
  139. if (isset($body['reenrich'])) {
  140. $params['reenrich'] = (bool) $body['reenrich'];
  141. }
  142. return $params;
  143. }
  144. private static function formatTs(mixed $value): ?string
  145. {
  146. if (!$value instanceof \DateTimeImmutable) {
  147. return null;
  148. }
  149. return $value->format('Y-m-d\TH:i:s\Z');
  150. }
  151. }