'POST', 'callback' => [$this, 'receive'], // offen, weil DomainWarn aufruft und keine Sitzung hat; geprüft wird über das state 'permission_callback' => '__return_true', ]); } /** Die Adresse, an die DomainWarn das Token schickt. */ public static function callbackUrl(): string { return rest_url(self::ROUTE_NAMESPACE.self::ROUTE); } /** * Startadresse bei DomainWarn: Sie trägt ein frisches `state`, das hier hinterlegt wird. Nur beim Klick aufrufen, * nicht beim Anzeigen der Seite – jeder Aufruf legt ein `state` an. */ public function startUrl(Settings $settings, string $returnUrl): string { $state = bin2hex(random_bytes(20)); set_transient(self::stateKey($state), 1, self::STATE_TTL); // http_build_query statt add_query_arg: Letzteres kodiert die Werte nicht, und die Rückrufadresse trägt // ohne hübsche Permalinks selbst ein Fragezeichen (…/index.php?rest_route=…). Unkodiert zerfiele alles // dahinter in eigene Parameter – die Bestätigungsseite bekäme eine abgeschnittene Adresse. return $this->connectPage($settings).'?'.http_build_query(array_filter([ 'app' => 'wordpress', 'site' => home_url(), 'callback' => self::callbackUrl(), 'return' => self::returnUrl(home_url(), $returnUrl), 'state' => $state, ], static fn (string $value) => $value !== ''), '', '&', PHP_QUERY_RFC3986); } /** * Die Rückkehradresse, sofern sie auf dem Host der Website liegt – nur dann nimmt die Bestätigungsseite sie an. * * Die Website (home_url) und der Rückruf (rest_url) teilen sich den Host; das Backend (admin_url) liegt auf dem * der WordPress-Adresse (site_url). Sind beide Hosts verschieden (WP_HOME ≠ WP_SITEURL), ist das Backend nur * unter seinem eigenen Host erreichbar – eine umgebogene Adresse führte ins Leere. Dann gibt es keine Rückkehr: * Die Verbindung kommt trotzdem zustande (das Token geht von Server zu Server), und die Bestätigungsseite sagt, * dass es getan ist; zurück ins Backend geht es von Hand. */ public static function returnUrl(string $siteUrl, string $returnUrl): string { $site = parse_url($siteUrl, PHP_URL_HOST); $return = parse_url($returnUrl, PHP_URL_HOST); if (! is_string($site) || ! is_string($return)) { return ''; } return strtolower(rtrim($site, '.')) === strtolower(rtrim($return, '.')) ? $returnUrl : ''; } /** * Die Bestätigungsseite gehört zum Dashboard, nicht zur API: aus api.domainwarn.com wird app.domainwarn.com. * Bei einer eigenen Installation trägt die API-Adresse ohnehin den eigenen Host, dann bleibt er stehen. */ private function connectPage(Settings $settings): string { $host = (string) wp_parse_url($settings->apiBaseUrl, PHP_URL_HOST); $app = str_starts_with($host, 'api.') ? 'app.'.substr($host, 4) : $host; $scheme = (string) (wp_parse_url($settings->apiBaseUrl, PHP_URL_SCHEME) ?: 'https'); $port = wp_parse_url($settings->apiBaseUrl, PHP_URL_PORT); return $scheme.'://'.$app.($port !== null ? ':'.$port : '').'/connect'; } /** * Das Token entgegennehmen. Kein `state`, ein falsches oder ein abgelaufenes: 403, und die Einstellungen * bleiben, wie sie waren. */ public function receive(WP_REST_Request $request): WP_REST_Response|WP_Error { $state = (string) $request->get_param('state'); // ausgegeben wird es als 40 Hex-Zeichen; alles andere kann kein eigenes sein if (preg_match('/^[0-9a-f]{40}$/', $state) !== 1 || get_transient(self::stateKey($state)) === false) { return new WP_Error('domainwarn_state', __('This connection request is unknown or has expired.', 'domainwarn'), ['status' => 403]); } // verbraucht, ob es klappt oder nicht: ein zweites Mal gilt es nicht delete_transient(self::stateKey($state)); $token = trim((string) $request->get_param('token')); if ($token === '') { return new WP_Error('domainwarn_token', __('No token received.', 'domainwarn'), ['status' => 422]); } $organization = $request->get_param('organization'); $slug = is_array($organization) && isset($organization['slug']) ? (string) $organization['slug'] : ''; $settings = $this->site->settings(); $this->site->saveSettings(new Settings( organization: $slug, token: $token, apiBaseUrl: $settings->apiBaseUrl, customerName: $settings->customerName, securityTxt: $settings->securityTxt, securityTxtBaseUrl: $settings->securityTxtBaseUrl, platformHealth: $settings->platformHealth, )); return new WP_REST_Response(['ok' => true], 200); } /** * Transient eines `state`. Gespeichert wird nur der Hash im Namen: Wer die Optionen-Tabelle lesen kann, findet * dort kein gültiges `state`, und das Nachschlagen ist ein exakter Schlüsselvergleich in der Datenbank. */ public static function stateKey(string $state): string { return self::STATE_PREFIX.substr(hash('sha256', $state), 0, 40); } }