token !== ''; } /** * Das Kürzel, mit dem gearbeitet wird – eingetragen oder beim ersten Zugriff aus dem Token ermittelt. Der * Aufrufer kann es danach abfragen und sich merken; dann bleibt es bei einer Abfrage. */ public function organization(): string { if ($this->organization === '') { foreach ($this->organizations() as $organization) { $this->organization = $organization['slug']; break; } } return $this->organization; } /** * Die Organisationen, die dieses Token sehen darf. * * @return list */ public function organizations(): array { $response = $this->request('GET', '/auth/user'); if ($response->status === 401) { return []; } if (! $response->successful()) { throw new TransportException($this->detail($response) ?? 'Das Konto konnte nicht gelesen werden (HTTP '.$response->status.').'); } $out = []; foreach ($response->json()['data']['organizations'] ?? [] as $organization) { if (isset($organization['slug']) && is_string($organization['slug'])) { $out[] = [ 'slug' => $organization['slug'], 'name' => is_string($organization['name'] ?? null) ? $organization['name'] : $organization['slug'], ]; } } return $out; } /** * Prüft den Zugang und ermittelt dabei die Organisation: Das Konto antwortet nur mit gültigem Token, und die * Antwort nennt die Organisationen, zu denen das Token gehört. Damit muss niemand ein Kürzel heraussuchen – * es stand bisher nirgends sichtbar. * * Geliefert wird ein Schlüssel, kein Satz – übersetzt wird dort, wo die Sprache des Lesers bekannt ist. * * @return array{ok: bool, message: Message, organization?: string, organization_name?: string} */ public function check(): array { if (! $this->configured()) { return ['ok' => false, 'message' => Message::of('connection.missing_config')]; } try { $organizations = $this->organizations(); } catch (TransportException $e) { return ['ok' => false, 'message' => Message::of('connection.unreachable', ['detail' => $e->getMessage()])]; } if ($organizations === []) { return ['ok' => false, 'message' => Message::of('connection.unauthorized')]; } $slugs = array_column($organizations, 'slug'); if ($this->organization === '') { // der Normalfall: ein Token, eine Organisation – das Kürzel ergibt sich daraus $this->organization = $slugs[0]; } elseif (! in_array($this->organization, $slugs, true)) { return ['ok' => false, 'message' => Message::of('connection.unknown_organization')]; } return [ 'ok' => true, 'message' => Message::of('connection.ok'), 'organization' => $this->organization, 'organization_name' => $organizations[array_search($this->organization, $slugs, true)]['name'], ]; } /** * Die gesuchten Domains in DomainWarn, mit Zustand. * * Zwei Dinge, die hier nicht offensichtlich sind: Gefragt wird je Hostname einzeln, weil die Domainliste * seitenweise kommt (höchstens 200) und eine Agentur leicht mehr führt – eine abgeschnittene erste Seite ließe * eigene Domains als „nicht überwacht“ erscheinen. Und DomainWarn führt Domains auf der registrierbaren Ebene: * Eine Website unter blog.kunde.de gehört dort zur Domain kunde.de. Deshalb wird, wenn der volle Hostname nichts * findet, Label für Label gekürzt weitergesucht, statt eine Liste öffentlicher Endungen mitzuschleppen. * * @param list $hostnames * @return array Schlüssel ist der Hostname der Website */ public function findDomains(array $hostnames): array { $found = []; foreach ($hostnames as $hostname) { $hostname = strtolower($hostname); foreach (self::candidates($hostname) as $candidate) { $domain = $this->searchDomain($candidate); if ($domain !== null) { $found[$hostname] = $domain; break; } } } return $found; } /** * Der Hostname und seine übergeordneten Domains, von genau nach allgemein: shop.kunde.de, kunde.de. Kürzer als * zwei Labels wird nicht gesucht – „de“ wäre keine Domain. * * @return list */ public static function candidates(string $hostname): array { $labels = explode('.', strtolower(trim($hostname, '.'))); $out = []; for ($i = 0, $max = count($labels) - 2; $i <= $max; $i++) { $out[] = implode('.', array_slice($labels, $i)); } return $out; } /** * @return ?array{id: string, name: string, health_status: string, is_paused: bool} */ private function searchDomain(string $name): ?array { $response = $this->request('GET', '/orgs/'.rawurlencode($this->organization()).'/domains', ['search' => $name, 'per_page' => 200]); if (! $response->successful()) { throw new TransportException($this->detail($response) ?? 'Die Domainliste konnte nicht gelesen werden (HTTP '.$response->status.').'); } foreach ($response->json()['data'] ?? [] as $domain) { // die Suche der API ist unscharf (enthält); gemeint ist nur der genaue Name if (! isset($domain['id'], $domain['name']) || strtolower((string) $domain['name']) !== $name) { continue; } return [ 'id' => (string) $domain['id'], 'name' => $name, 'health_status' => (string) ($domain['health_status'] ?? 'unknown'), 'is_paused' => (bool) ($domain['is_paused'] ?? false), ]; } return null; } /** * @return array{id: string, name: string} */ public function createDomain(string $name, ?string $customerName = null): array { $response = $this->request('POST', '/orgs/'.rawurlencode($this->organization()).'/domains', null, array_filter([ 'name' => $name, 'customer_name' => $customerName, ], static fn ($value) => $value !== null)); if (! $response->successful()) { throw new TransportException($this->detail($response) ?? 'Die Domain '.$name.' konnte nicht angelegt werden (HTTP '.$response->status.').'); } $data = $response->json()['data'] ?? []; return ['id' => (string) ($data['id'] ?? ''), 'name' => strtolower((string) ($data['name'] ?? $name))]; } /** * Die Kunden der Organisation, für die Auswahl in den Einstellungen. Ein freies Textfeld legt bei jedem Tippfehler * einen neuen Kunden an; die Liste zeigt, was es wirklich gibt. * * @return list */ public function customers(): array { $response = $this->request('GET', '/orgs/'.rawurlencode($this->organization()).'/customers', ['per_page' => 200]); if (! $response->successful()) { throw new TransportException($this->detail($response) ?? 'Die Kunden konnten nicht gelesen werden (HTTP '.$response->status.').'); } $out = []; foreach ($response->json()['data'] ?? [] as $customer) { $name = $customer['name'] ?? null; if (is_string($name) && $name !== '') { $out[] = ['id' => (string) ($customer['id'] ?? ''), 'name' => $name]; } } return $out; } /** * Innensicht melden. Die Website meldet nur Tatsachen; bewertet wird in DomainWarn. * * @param array $payload */ public function reportInsight(string $domainId, array $payload): void { $response = $this->request('POST', '/orgs/'.rawurlencode($this->organization()).'/domains/'.$domainId.'/site-insights', null, $payload); if (! $response->successful()) { throw new TransportException($this->detail($response) ?? 'Die Innensicht konnte nicht gemeldet werden (HTTP '.$response->status.').'); } } /** * Offene Vorfälle einer Domain. Gefragt wird je Domain, nicht für die ganze Organisation: Die Liste kommt * seitenweise und nach Schwere sortiert – in einer Agentur mit vielen Kunden fielen die eigenen Vorfälle sonst * hinter die erste Seite. * * @return list */ public function openIncidents(string $domainId, string $hostname): array { $response = $this->request('GET', '/orgs/'.rawurlencode($this->organization()).'/incidents', ['status' => 'open', 'domain' => $domainId, 'per_page' => 100]); if (! $response->successful()) { throw new TransportException($this->detail($response) ?? 'Die Vorfälle konnten nicht gelesen werden (HTTP '.$response->status.').'); } $out = []; foreach ($response->json()['data'] ?? [] as $incident) { if (! isset($incident['id'], $incident['type'])) { continue; } $out[] = [ 'id' => (string) $incident['id'], 'domain' => $hostname, 'type' => (string) $incident['type'], 'severity' => (string) ($incident['severity'] ?? 'warning'), 'status' => (string) ($incident['status'] ?? 'open'), 'started_at' => isset($incident['started_at']) && is_string($incident['started_at']) ? $incident['started_at'] : null, ]; } return $out; } /** * Monitore einer Domain – die Erweiterung sucht darin ihren eigenen Heartbeat. * * @return list */ public function monitors(string $domainId): array { $response = $this->request('GET', '/orgs/'.rawurlencode($this->organization()).'/domains/'.$domainId.'/monitors'); if (! $response->successful()) { throw new TransportException($this->detail($response) ?? 'Die Prüfungen der Domain konnten nicht gelesen werden (HTTP '.$response->status.').'); } $out = []; foreach ($response->json()['data'] ?? [] as $monitor) { if (isset($monitor['id'], $monitor['type'])) { $out[] = [ 'id' => (string) $monitor['id'], 'type' => (string) $monitor['type'], 'target' => (string) ($monitor['target'] ?? ''), 'ping_url' => isset($monitor['ping_url']) && is_string($monitor['ping_url']) ? $monitor['ping_url'] : null, ]; } } return $out; } /** * Legt den Heartbeat an, über den der geplante Lauf sich meldet. Antwortet die API mit der * Ping-Adresse, merkt die Erweiterung sie sich – ein zweiter Heartbeat gleichen Namens wird abgelehnt. */ public function createHeartbeat(string $domainId, string $name, int $periodSeconds, int $graceSeconds): ?string { $response = $this->request('POST', '/orgs/'.rawurlencode($this->organization()).'/domains/'.$domainId.'/monitors', null, [ 'type' => 'heartbeat', 'target' => $name, 'config' => ['period_seconds' => $periodSeconds, 'grace_seconds' => $graceSeconds], ]); if (! $response->successful()) { throw new TransportException($this->detail($response) ?? 'Der Heartbeat konnte nicht angelegt werden (HTTP '.$response->status.').'); } $url = $response->json()['data']['ping_url'] ?? null; return is_string($url) ? $url : null; } /** * Meldet den Lauf der geplanten Aufgabe. Ohne Token: Die Adresse trägt ihn, damit der Ping auch aus einem * abgesicherten Worker ohne Zugangsdaten abgesetzt werden kann. */ public function ping(string $pingUrl, bool $failed = false): void { $response = $this->transport->request('POST', rtrim($pingUrl, '/').($failed ? '/fail' : ''), ['Accept' => 'application/json']); if (! $response->successful()) { throw new TransportException('Der Heartbeat wurde nicht angenommen (HTTP '.$response->status.').', $response->status); } } /** * Stand der von DomainWarn gehosteten Funktionen einer Domain: security.txt samt Ziel der Weiterleitung, MTA-STS und * BIMI samt Records, dazu die Seiten im Dashboard. null, wenn die API den Endpunkt nicht kennt (eine ältere * eigene Installation) – dann gibt es eben nichts zu zeigen, und nichts wird weitergeleitet. * * @return ?array */ public function hosting(string $domainId): ?array { $response = $this->request('GET', '/orgs/'.rawurlencode($this->organization()).'/domains/'.rawurlencode($domainId).'/hosting'); if ($response->status === 404) { return null; } if (! $response->successful()) { throw new TransportException($this->detail($response) ?? 'Der Stand der gehosteten Funktionen konnte nicht gelesen werden (HTTP '.$response->status.').'); } $data = $response->json()['data'] ?? null; return is_array($data) ? $data : null; } /** * Eine Domain mit ihren Prüfungen (monitors, samt letztem Ergebnis) und der Zahl offener Vorfälle * (open_incidents_count). null, wenn es die Domain dort nicht (mehr) gibt – gelöscht oder in eine andere * Organisation verschoben; dann gibt es zu ihr nichts zu zeigen. * * @return ?array */ public function domain(string $domainId): ?array { $response = $this->request('GET', '/orgs/'.rawurlencode($this->organization()).'/domains/'.rawurlencode($domainId)); if ($response->status === 404) { return null; } if (! $response->successful()) { throw new TransportException($this->detail($response) ?? 'Die Domain konnte nicht gelesen werden (HTTP '.$response->status.').'); } $data = $response->json()['data'] ?? null; return is_array($data) ? $data : null; } /** * Erreichbarkeit und Antwortzeiten einer Prüfung über einen Zeitraum (24h, 7d, 30d, 90d): summary mit * uptime_ratio, latency_p50 usw., points mit den Werten je Stunde oder Tag. null, wenn die Prüfung nicht (mehr) * existiert oder die API den Endpunkt nicht kennt. * * @return ?array */ public function uptime(string $monitorId, string $range): ?array { $response = $this->request('GET', '/orgs/'.rawurlencode($this->organization()).'/monitors/'.rawurlencode($monitorId).'/uptime', ['range' => $range]); if ($response->status === 404) { return null; } if (! $response->successful()) { throw new TransportException($this->detail($response) ?? 'Die Erreichbarkeit konnte nicht gelesen werden (HTTP '.$response->status.').'); } $data = $response->json()['data'] ?? null; return is_array($data) ? $data : null; } /** * @param ?array $query * @param ?array $json */ private function request(string $method, string $path, ?array $query = null, ?array $json = null): TransportResponse { $url = rtrim($this->baseUrl, '/').'/api/v1'.$path; if ($query !== null && $query !== []) { $url .= '?'.http_build_query($query); } return $this->transport->request($method, $url, [ 'Authorization' => 'Bearer '.$this->token, 'Accept' => 'application/json', ], $json); } /** Fehlertext der API (RFC 9457) inklusive der ersten Feldmeldung, sonst null. */ private function detail(TransportResponse $response): ?string { $problem = $response->json(); $detail = is_string($problem['detail'] ?? null) ? $problem['detail'] : (is_string($problem['title'] ?? null) ? $problem['title'] : null); $errors = is_array($problem['errors'] ?? null) ? $problem['errors'] : []; foreach ($errors as $messages) { if (is_array($messages) && isset($messages[0]) && is_string($messages[0])) { return trim(($detail ?? '').' '.$messages[0]); } } return $detail; } }