credentialsPath(); return (bool) config('search_console.enabled') && $file !== null && is_readable($file); } /** * Pfad der Schlüsseldatei, relative Angaben ab Projektwurzel. Ohne das * hing die Lesbarkeit vom Arbeitsverzeichnis ab: im Cron (Projektwurzel) * lesbar, im Web-Request (public/) nicht — der Admin meldete „nicht * konfiguriert", während der Lauf längst Daten holte. */ private function credentialsPath(): ?string { $file = trim((string) config('search_console.credentials')); if ($file === '') { return null; } return str_starts_with($file, '/') ? $file : base_path($file); } public function propertyFor(string $portal): ?string { $property = config('search_console.properties.'.$portal); return is_string($property) && $property !== '' ? $property : null; } /** * Fragt den Indexierungsstatus einer URL ab und liefert * `inspectionResult` zurück (mit `indexStatusResult`). * * @return array */ public function inspect(string $url, string $siteUrl, string $languageCode = 'de-DE'): array { $response = Http::withToken($this->accessToken()) ->acceptJson() ->timeout(20) ->post(self::INSPECT_URL, [ 'inspectionUrl' => $url, 'siteUrl' => $siteUrl, 'languageCode' => $languageCode, ]); if (! $response->successful()) { throw new RuntimeException(sprintf( 'URL Inspection API antwortet mit %d für %s: %s', $response->status(), $url, (string) ($response->json('error.message') ?? $response->body()), )); } $result = $response->json('inspectionResult'); return is_array($result) ? $result : []; } /** * Zugriffstoken des Service-Kontos, 50 Minuten gecacht (Gültigkeit 60). */ private function accessToken(): string { return Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(50), function (): string { $credentials = $this->credentials(); $now = time(); $assertion = $this->jwt([ 'iss' => $credentials['client_email'], 'scope' => self::SCOPE, 'aud' => $credentials['token_uri'], 'iat' => $now, 'exp' => $now + 3600, ], $credentials['private_key']); $response = Http::asForm()->timeout(20)->post($credentials['token_uri'], [ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $assertion, ]); $token = $response->json('access_token'); if (! $response->successful() || ! is_string($token) || $token === '') { throw new RuntimeException('Search Console: kein Zugriffstoken erhalten ('.$response->status().').'); } return $token; }); } /** * @return array{client_email: string, private_key: string, token_uri: string} */ private function credentials(): array { $file = $this->credentialsPath(); $json = $file !== null && is_readable($file) ? json_decode((string) file_get_contents($file), true) : null; if (! is_array($json) || empty($json['client_email']) || empty($json['private_key'])) { throw new RuntimeException('Search Console: Schlüsseldatei des Service-Kontos fehlt oder ist unvollständig.'); } return [ 'client_email' => (string) $json['client_email'], 'private_key' => (string) $json['private_key'], 'token_uri' => (string) ($json['token_uri'] ?? 'https://oauth2.googleapis.com/token'), ]; } /** * @param array $claims */ private function jwt(array $claims, string $privateKey): string { $segments = [ $this->base64Url((string) json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), $this->base64Url((string) json_encode($claims)), ]; $key = openssl_pkey_get_private($privateKey); if ($key === false) { throw new RuntimeException('Search Console: privater Schlüssel des Service-Kontos ist nicht lesbar.'); } $signature = ''; openssl_sign(implode('.', $segments), $signature, $key, OPENSSL_ALGO_SHA256); $segments[] = $this->base64Url($signature); return implode('.', $segments); } private function base64Url(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); } }