noticeKey(), $this->perform($action), 60); wp_safe_redirect(add_query_arg('page', self::MENU_SLUG, admin_url('options-general.php'))); exit; } /** * Eine Aktion des Formulars ausführen. Recht und Nonce prüft handle() vorher; öffentlich nur für den Test. * * @return array{type: string, text: string} */ public function perform(string $action): array { // Ein Formular, fünf Knöpfe: Was im Formular steht, gilt auch für „Verbindung prüfen“, „Adressen aufnehmen“ // und die übrigen. Sonst prüfte der erste Klick nach dem Eintragen des Tokens die leeren gespeicherten // Einstellungen, meldete „Trage zuerst das API-Token ein“ – und das eingetippte Token war weg. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer() in handle() if ($action !== 'save' && isset($_POST['api_base_url'])) { $this->store(); } return match ($action) { 'save' => $this->save(), 'test' => $this->test(), 'sync' => $this->sync(), 'report' => $this->report(), 'check_security_txt' => $this->checkSecurityTxt(), default => ['type' => 'error', 'text' => __('Unknown action.', 'domainwarn')], }; } /** * „Mit DomainWarn verbinden“: erst hier entsteht das `state` und es geht weiter zur Bestätigungsseite. Beim * Anzeigen der Einstellungsseite erzeugt, überschrieb jeder Seitenaufruf (zweiter Tab, zweiter Admin) das einer * gerade laufenden Verbindung – der Rückruf scheiterte dann mit „unbekannt oder abgelaufen“. */ public function connect(): void { if (! current_user_can(self::capability())) { wp_die(esc_html__('You are not allowed to do this.', 'domainwarn'), 403); } check_admin_referer(self::CONNECT_ACTION); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Ziel ist bewusst die Bestätigungsseite von DomainWarn (anderer Host); die Adresse baut Connect::startUrl aus der eingestellten API-Adresse. wp_redirect($this->connect->startUrl($this->site->settings(), $this->pageUrl())); exit; } /** * @return array{type: string, text: string} */ private function save(): array { [$current, $settings] = $this->store(); // gerade eingeschaltet: gleich prüfen, ob die Datei ankommt und ob die Erweiterung jemanden überdeckt – ein Konflikt // soll hier auffallen, nicht erst beim nächsten Besuch eines Forschers if ($settings->securityTxt && ! $current->securityTxt && $settings->complete()) { $check = $this->checkSecurityTxt(); return ['type' => $check['type'], 'text' => __('Saved.', 'domainwarn').' '.$check['text']]; } return ['type' => 'success', 'text' => __('Saved.', 'domainwarn')]; } /** * Die Felder des Formulars speichern. * * @return array{0: Settings, 1: Settings} bisheriger und neuer Stand */ private function store(): array { // phpcs:disable WordPress.Security.NonceVerification.Missing -- check_admin_referer() in handle() $current = $this->site->settings(); $token = isset($_POST['token']) ? trim(sanitize_text_field(wp_unslash($_POST['token']))) : ''; $settings = Settings::fromArray([ 'organization' => isset($_POST['organization']) ? sanitize_text_field(wp_unslash($_POST['organization'])) : '', // ein leeres Feld bedeutet „unverändert“: das gespeicherte Token wird nie im Formular angezeigt 'token' => $token === '' ? $current->token : $token, 'api_base_url' => isset($_POST['api_base_url']) ? esc_url_raw(wp_unslash($_POST['api_base_url'])) : Settings::DEFAULT_API_BASE_URL, 'customer_name' => isset($_POST['customer_name']) ? sanitize_text_field(wp_unslash($_POST['customer_name'])) : '', 'heartbeat_url' => $current->heartbeatUrl, 'security_txt' => isset($_POST['security_txt']), 'security_txt_base_url' => isset($_POST['security_txt_base_url']) ? esc_url_raw(wp_unslash($_POST['security_txt_base_url'])) : '', 'platform_health' => isset($_POST['platform_health']), ]); // phpcs:enable WordPress.Security.NonceVerification.Missing if (! $settings->sameAccess($current)) { // anderes Token oder Kürzel: Die gemerkte Ping-Adresse gehört zur alten Organisation; der nächste Lauf // richtet den Heartbeat dort ein, wo die Domain jetzt liegt $settings = $settings->withHeartbeatUrl(''); } $this->site->saveSettings($settings); // nach einem Tokenwechsel gehört die alte Kundenliste einer anderen Organisation delete_transient(self::CUSTOMERS_KEY); return [$current, $settings]; } /** * „Prüfen“: Stand bei DomainWarn neu holen und /.well-known/security.txt so abrufen, wie es ein Forscher täte. * * @return array{type: string, text: string} */ private function checkSecurityTxt(): array { if (! $this->site->settings()->complete()) { return ['type' => 'error', 'text' => Translator::text(Message::of('connection.missing_config'))]; } $check = $this->securityTxt->check(); $texts = []; $ok = true; foreach ($check['results'] as $result) { $ok = $ok && in_array($result['state'], ['ok', 'redirected', 'missing'], true); $texts[] = $result['host'].': '.Translator::text(new Message($result['key'], $result['params'])); } if ($texts === []) { $texts[] = __('This site has no address that can be reached from outside.', 'domainwarn'); } return ['type' => $check['conflict'] ? 'warning' : ($ok ? 'success' : 'warning'), 'text' => implode(' ', $texts)]; } /** * @return array{type: string, text: string} */ private function test(): array { $settings = $this->site->settings(); $result = $this->factory->create($settings)->check(); if (! $result['ok']) { return ['type' => 'error', 'text' => Translator::text($result['message'])]; } // das Kürzel kommt aus dem Token; gemerkt spart es jedem weiteren Aufruf die Nachfrage if (($result['organization'] ?? '') !== '' && $result['organization'] !== $settings->organization) { $this->site->saveSettings($settings->withOrganization($result['organization'])); } return [ 'type' => 'success', 'text' => Translator::text(Message::of('connection.ok_organization', [ 'organization' => $result['organization_name'] ?? ($result['organization'] ?? ''), ])), ]; } /** * @return array{type: string, text: string} */ private function sync(): array { $settings = $this->site->settings(); if (! $settings->complete()) { return ['type' => 'error', 'text' => Translator::text(Message::of('connection.missing_config'))]; } try { $result = (new DomainSync($this->factory->create($settings)))->run( $this->site->siteUrls(), $settings->customerName !== '' ? $settings->customerName : null, ); } catch (TransportException $e) { return ['type' => 'error', 'text' => Translator::text(Message::of('connection.unreachable', ['detail' => $e->getMessage()]))]; } return [ 'type' => $result['failed'] === [] ? 'success' : 'warning', 'text' => Translator::text(Message::of('sync.summary', [ 'created' => count($result['created']), 'existing' => count($result['existing']), 'failed' => count($result['failed']), ])), ]; } /** * @return array{type: string, text: string} */ private function report(): array { try { // auf Knopfdruck auch die Kennzahlen des Dashboard-Widgets neu holen: wer klickt, will den aktuellen Stand sehen $lines = $this->report->run(true); } catch (TransportException $e) { // der Ablauf fängt Ausfälle selbst ab; bleibt doch einer übrig, wird er ein Hinweis, kein Fatal Error return ['type' => 'error', 'text' => Translator::text(Message::of('connection.unreachable', ['detail' => $e->getMessage()]))]; } $texts = array_map( static fn (array $line) => ($line['host'] !== null ? $line['host'].': ' : '').Translator::text($line['message']), $lines, ); return ['type' => 'success', 'text' => implode(' ', $texts)]; } public function render(): void { if (! current_user_can(self::capability())) { return; } $settings = $this->site->settings(); $hosts = DomainCollector::hostnames($this->site->siteUrls()); $customers = $this->customers($settings); $notice = get_transient($this->noticeKey()); delete_transient($this->noticeKey()); $notice = $this->connectResult() ?? $notice; $connectUrl = wp_nonce_url(admin_url('admin-post.php?action='.self::CONNECT_ACTION), self::CONNECT_ACTION); $hosting = $this->site->hostingSnapshot(); $securityTxtCheck = SecurityTxtResponder::lastCheck(); $securityTxtFile = SecurityTxtResponder::physicalFile(); $atDomainRoot = SecurityTxtResponder::atDomainRoot(); $securityTxtBase = $settings->securityTxtBase(); require __DIR__.'/views/settings-page.php'; } /** * Zustand einer gehosteten Funktion, wie DomainWarn ihn meldet, als kurzer Satz. Die Zustände heißen wie im * Dashboard (HostingStatus in der API). * * „outdated“ heißt bei der security.txt etwas anderes als bei einem Record: Unter der Adresse liegt die Datei von * DomainWarn, aber eine alte Fassung – die Website liefert die letzte gute aus, weil sie DomainWarn länger nicht * erreicht hat. */ public static function stateLabel(string $state, string $feature = ''): string { if ($feature === 'security_txt' && $state === 'outdated') { return __('Outdated copy – DomainWarn has not been reachable for a while', 'domainwarn'); } return match ($state) { 'active' => __('Active', 'domainwarn'), 'off' => __('Not set up', 'domainwarn'), 'pending' => __('Not checked yet', 'domainwarn'), 'missing' => __('Record missing', 'domainwarn'), 'outdated' => __('Record outdated', 'domainwarn'), 'elsewhere' => __('Points elsewhere', 'domainwarn'), 'unreachable' => __('Policy file not reachable (CNAME missing?)', 'domainwarn'), 'noMx' => __('No mail server (MX) found', 'domainwarn'), default => __('Unknown', 'domainwarn'), }; } /** Ton des Zustands für die Farbe des Hinweises: aktiv grün, ungeprüft und aus neutral, alles andere gelb. */ public static function stateTone(string $state): string { return match ($state) { 'active' => 'success', 'off', 'pending', 'unknown' => 'info', default => 'warning', }; } /** Die eigene Adresse im Backend – Ziel der Rückkehr aus DomainWarn. */ private function pageUrl(): string { return admin_url('options-general.php?page='.self::MENU_SLUG); } /** * Rückmeldung aus DomainWarn: Die Bestätigungsseite hängt ?domainwarn=connected|denied an. * * @return ?array{type: string, text: string} */ private function connectResult(): ?array { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nur eine Anzeige; geändert wird nichts. $result = isset($_GET['domainwarn']) ? sanitize_key(wp_unslash($_GET['domainwarn'])) : ''; return match ($result) { 'connected' => ['type' => 'success', 'text' => __('Connected. The token is stored on this site.', 'domainwarn')], 'denied' => ['type' => 'warning', 'text' => __('The connection was cancelled.', 'domainwarn')], default => null, }; } /** * Die Kunden der Organisation für die Auswahl. Ohne Zugang oder bei einer unerreichbaren API gibt es keine * Liste – dann bleibt das Feld ein Textfeld, statt dass die Seite mit einem Fehler stehen bleibt. * * Zwischengespeichert, weil die Seite sonst bei jedem Aufruf eine Abfrage nach außen machte. * * @return ?list */ private function customers(Settings $settings): ?array { if (! $settings->complete()) { return null; } $cached = get_transient(self::CUSTOMERS_KEY); if (is_array($cached)) { return $cached; } try { $customers = $this->factory->create($settings)->customers(); } catch (TransportException) { return null; } set_transient(self::CUSTOMERS_KEY, $customers, self::CUSTOMERS_TTL); return $customers; } /** Die Meldung gehört zum Benutzer, nicht zur Website: Zwei Admins sollen sich nicht gegenseitig Meldungen zeigen. */ private function noticeKey(): string { return 'domainwarn_notice_'.get_current_user_id(); } }