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 $body * @return array */ 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'); } }