| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173 |
- <?php
- declare(strict_types=1);
- namespace App\Application\Admin;
- use App\Application\Jobs\RefreshGeoipJob;
- use App\Domain\Audit\AuditAction;
- use App\Domain\Audit\AuditEmitter;
- use App\Domain\Time\Clock;
- use App\Infrastructure\Enrichment\Downloaders\GeoIpDownloader;
- use App\Infrastructure\Jobs\JobLockRepository;
- use App\Infrastructure\Jobs\JobRegistry;
- use App\Infrastructure\Jobs\JobRunner;
- use App\Infrastructure\Jobs\JobRunRepository;
- use Psr\Http\Message\ResponseInterface;
- use Psr\Http\Message\ServerRequestInterface;
- /**
- * Admin-side proxy for the internal jobs surface.
- *
- * - `GET /api/v1/admin/jobs/status` (Viewer): mirrors the data from
- * `/internal/jobs/status` but is reachable without the internal
- * token, and never short-circuits 404/401 on RFC1918 boundaries.
- * - `POST /api/v1/admin/jobs/trigger/{name}` (Admin): invokes the
- * same Job class the internal handler does, but with
- * `triggered_by="manual"`. Emits one `job.triggered` audit row.
- * refresh-geoip honours the same 412-no-credential short-circuit
- * the internal handler implements.
- *
- * The split exists so the UI never has to forward through the internal
- * token (which is bound to RFC1918 networks). Per SPEC §6/§7.
- */
- final class JobsAdminController
- {
- use AdminControllerSupport;
- public function __construct(
- private readonly JobRegistry $registry,
- private readonly JobRunner $runner,
- private readonly JobRunRepository $runs,
- private readonly JobLockRepository $locks,
- private readonly Clock $clock,
- private readonly GeoIpDownloader $geoipDownloader,
- private readonly AuditEmitter $audit,
- ) {
- }
- public function status(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
- {
- $now = $this->clock->now();
- $latest = $this->runs->latestPerJob();
- $jobs = [];
- foreach ($this->registry->all() as $name => $job) {
- $row = $latest[$name] ?? null;
- $finishedAt = $row['finished_at'] ?? null;
- $overdue = $row === null
- || ($finishedAt instanceof \DateTimeImmutable
- && ($now->getTimestamp() - $finishedAt->getTimestamp()) > $job->defaultIntervalSeconds());
- $jobs[$name] = [
- 'name' => $name,
- 'default_interval_seconds' => $job->defaultIntervalSeconds(),
- 'max_runtime_seconds' => $job->maxRuntimeSeconds(),
- 'overdue' => $overdue,
- 'lock' => $this->locks->status($name),
- 'last_run' => $row === null ? null : [
- 'id' => $row['id'],
- 'status' => $row['status'],
- 'items_processed' => $row['items_processed'],
- 'triggered_by' => $row['triggered_by'],
- 'started_at' => self::formatTs($row['started_at']),
- 'finished_at' => self::formatTs($row['finished_at']),
- 'error_message' => $row['error_message'],
- ],
- ];
- }
- return self::json($response, 200, [
- 'now' => $now->format('Y-m-d\TH:i:s\Z'),
- 'jobs' => $jobs,
- ]);
- }
- /**
- * @param array{name: string} $args
- */
- public function trigger(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
- {
- $name = $args['name'];
- if (!$this->registry->has($name)) {
- return self::error($response, 404, 'unknown_job');
- }
- // refresh-geoip's credential short-circuit: same 412 envelope the
- // internal handler returns. Don't take the lock or even start the
- // job for opt-in providers without their key.
- if ($name === RefreshGeoipJob::NAME
- && $this->geoipDownloader->requiresCredential()
- && !$this->geoipDownloader->hasCredential()) {
- $missing = match ($this->geoipDownloader->name()) {
- 'maxmind' => 'MAXMIND_LICENSE_KEY',
- 'ipinfo' => 'IPINFO_TOKEN',
- default => 'CREDENTIAL',
- };
- return self::json($response, 412, [
- 'error' => 'no_credential',
- 'provider' => $this->geoipDownloader->name(),
- 'missing' => $missing,
- ]);
- }
- $body = self::jsonBody($request);
- $params = self::sanitiseParams($body);
- // Audit BEFORE running the job — even if the job fails, we want a
- // record that it was invoked. SEC_REVIEW F4: emitOrThrow so a
- // failed audit insert produces a 500 instead of silently running
- // the job without a trigger row in audit_log.
- $this->audit->emitOrThrow(
- AuditAction::JOB_TRIGGERED,
- 'job',
- $name,
- ['name' => $name, 'params' => $params, 'triggered_by' => 'manual'],
- self::auditContext($request),
- $name,
- );
- $job = $this->registry->get($name);
- $outcome = $this->runner->run($job, $params, 'manual');
- $response = $response
- ->withStatus($outcome->httpStatus())
- ->withHeader('Content-Type', 'application/json');
- $response->getBody()->write((string) json_encode($outcome->toArray()));
- return $response;
- }
- /**
- * @param array<string, mixed> $body
- * @return array<string, mixed>
- */
- private static function sanitiseParams(array $body): array
- {
- // Whitelist the same params the internal handler accepts; ignore
- // anything else so a malicious admin can't smuggle config.
- $params = [];
- if (isset($body['full'])) {
- $params['full'] = (bool) $body['full'];
- }
- if (isset($body['max_rows']) && is_numeric($body['max_rows'])) {
- $params['max_rows'] = (int) $body['max_rows'];
- }
- if (isset($body['reenrich'])) {
- $params['reenrich'] = (bool) $body['reenrich'];
- }
- return $params;
- }
- private static function formatTs(mixed $value): ?string
- {
- if (!$value instanceof \DateTimeImmutable) {
- return null;
- }
- return $value->format('Y-m-d\TH:i:s\Z');
- }
- }
|