Files
SkinbaseNova/app/Services/Adsense/AdsenseOAuthService.php
T
test 4d60a876af Add a read-only Google AdSense analytics module for admins.
Connect AdSense over OAuth, sync entities and daily reports locally, and show placement performance in the admin panel without calling the Management API from public pages.
2026-09-20 14:49:48 +02:00

328 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Adsense;
use App\Models\AdsenseConnection;
use App\Services\Adsense\Exceptions\AdsenseAuthorizationException;
use App\Services\Adsense\Exceptions\AdsenseOAuthException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
final class AdsenseOAuthService
{
public const SESSION_STATE_KEY = 'adsense.oauth_state';
public const SESSION_PENDING_ACCOUNTS_KEY = 'adsense.pending_accounts';
public function isConfigured(): bool
{
return $this->clientId() !== '' && $this->clientSecret() !== '' && $this->redirectUri() !== '';
}
public function authorizationUrl(Request $request, bool $promptConsent = true): string
{
if (! $this->isConfigured()) {
throw new AdsenseOAuthException('Google AdSense OAuth is not configured.');
}
$state = bin2hex(random_bytes(32));
$request->session()->put(self::SESSION_STATE_KEY, $state);
$query = [
'client_id' => $this->clientId(),
'redirect_uri' => $this->redirectUri(),
'response_type' => 'code',
'scope' => (string) config('adsense.scope'),
'access_type' => 'offline',
'include_granted_scopes' => 'true',
'state' => $state,
];
if ($promptConsent) {
$query['prompt'] = 'consent';
}
return (string) config('adsense.oauth_authorize_url').'?'.http_build_query($query);
}
/**
* @return array{access_token: string, refresh_token: ?string, expires_in: int}
*/
public function exchangeAuthorizationCode(string $code): array
{
return $this->requestToken([
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->redirectUri(),
'client_id' => $this->clientId(),
'client_secret' => $this->clientSecret(),
]);
}
/**
* @return array{access_token: string, refresh_token: ?string, expires_in: int}
*/
public function refreshAccessToken(string $refreshToken): array
{
try {
return $this->requestToken([
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => $this->clientId(),
'client_secret' => $this->clientSecret(),
]);
} catch (AdsenseOAuthException $exception) {
if ($this->isInvalidGrant($exception)) {
throw new AdsenseAuthorizationException(
'AdSense authorization expired or was revoked.',
0,
$exception,
);
}
throw $exception;
}
}
public function persistTokens(AdsenseConnection $connection, array $tokens, ?int $userId = null): void
{
$refreshToken = $tokens['refresh_token'] ?? null;
if (is_string($refreshToken) && $refreshToken !== '') {
$connection->encrypted_refresh_token = $refreshToken;
}
if (! $connection->hasRefreshToken()) {
throw new AdsenseOAuthException('Google did not return a refresh token. Reconnect with consent.');
}
if ($userId !== null) {
$connection->connected_by_user_id = $userId;
}
if ($connection->connected_at === null) {
$connection->connected_at = now();
}
if ($connection->status === AdsenseConnection::STATUS_DISCONNECTED) {
$connection->status = AdsenseConnection::STATUS_PENDING;
}
$connection->last_error = null;
$connection->save();
if (isset($tokens['access_token']) && is_string($tokens['access_token']) && $tokens['access_token'] !== '') {
$this->cacheAccessToken(
(int) $connection->id,
$tokens['access_token'],
(int) ($tokens['expires_in'] ?? 3600),
);
}
}
public function accessToken(AdsenseConnection $connection, bool $forceRefresh = false): string
{
if (! $connection->hasRefreshToken()) {
$this->markReconnectRequired($connection, 'AdSense authorization expired or was revoked.');
throw new AdsenseAuthorizationException('AdSense authorization expired or was revoked.');
}
$cacheKey = $this->cacheKey((int) $connection->id);
if (! $forceRefresh) {
$cached = Cache::get($cacheKey);
if (is_string($cached) && $cached !== '') {
return $cached;
}
}
try {
$tokens = $this->refreshAccessToken((string) $connection->encrypted_refresh_token);
} catch (AdsenseAuthorizationException $exception) {
$this->forgetAccessToken((int) $connection->id);
$this->markReconnectRequired($connection, $exception->getMessage());
throw $exception;
}
$accessToken = (string) ($tokens['access_token'] ?? '');
if ($accessToken === '') {
throw new AdsenseOAuthException('Google did not return an access token.');
}
if (isset($tokens['refresh_token']) && is_string($tokens['refresh_token']) && $tokens['refresh_token'] !== '') {
$connection->encrypted_refresh_token = $tokens['refresh_token'];
$connection->save();
}
$this->cacheAccessToken((int) $connection->id, $accessToken, (int) ($tokens['expires_in'] ?? 3600));
return $accessToken;
}
public function cacheAccessToken(int $connectionId, string $accessToken, int $expiresIn): void
{
$ttl = max(30, $expiresIn - (int) config('adsense.access_token_skew_seconds', 60));
Cache::put($this->cacheKey($connectionId), $accessToken, $ttl);
}
public function forgetAccessToken(int $connectionId): void
{
Cache::forget($this->cacheKey($connectionId));
}
public function markReconnectRequired(AdsenseConnection $connection, string $message): void
{
$connection->status = AdsenseConnection::STATUS_RECONNECT_REQUIRED;
$connection->last_error = $message;
$connection->save();
}
public function disconnect(AdsenseConnection $connection): void
{
$refreshToken = $connection->encrypted_refresh_token;
$this->forgetAccessToken((int) $connection->id);
if (is_string($refreshToken) && $refreshToken !== '') {
$this->revokeToken($refreshToken);
}
$connection->encrypted_refresh_token = null;
$connection->status = AdsenseConnection::STATUS_DISCONNECTED;
$connection->last_error = null;
$connection->save();
}
public function revokeToken(string $token): void
{
try {
Http::asForm()
->timeout((int) config('adsense.timeout', 20))
->connectTimeout((int) config('adsense.connect_timeout', 5))
->post((string) config('adsense.oauth_revoke_url'), [
'token' => $token,
]);
} catch (\Throwable $exception) {
Log::warning('AdSense token revocation failed.', AdsenseLogSanitizer::context([
'message' => $exception->getMessage(),
]));
}
}
public function assertValidCallback(Request $request): string
{
$error = trim((string) $request->query('error', ''));
if ($error !== '') {
$description = trim((string) $request->query('error_description', ''));
$request->session()->forget(self::SESSION_STATE_KEY);
throw new AdsenseOAuthException(
$description !== ''
? 'Google AdSense authorization was denied: '.$description
: 'Google AdSense authorization was denied.'
);
}
$expected = (string) $request->session()->pull(self::SESSION_STATE_KEY, '');
$provided = (string) $request->query('state', '');
if ($expected === '' || $provided === '' || ! hash_equals($expected, $provided)) {
throw new AdsenseOAuthException('Invalid OAuth state.');
}
$code = trim((string) $request->query('code', ''));
if ($code === '') {
throw new AdsenseOAuthException('Missing authorization code.');
}
return $code;
}
/**
* @param array<string, string> $payload
* @return array{access_token: string, refresh_token: ?string, expires_in: int}
*/
private function requestToken(array $payload): array
{
try {
$response = Http::asForm()
->acceptJson()
->timeout((int) config('adsense.timeout', 20))
->connectTimeout((int) config('adsense.connect_timeout', 5))
->post((string) config('adsense.oauth_token_url'), $payload);
} catch (ConnectionException $exception) {
Log::warning('AdSense OAuth token request failed.', AdsenseLogSanitizer::context([
'message' => $exception->getMessage(),
]));
throw new AdsenseOAuthException('Unable to contact Google OAuth.', 0, $exception);
}
$json = $response->json();
$json = is_array($json) ? $json : [];
if ($response->failed()) {
$error = $json['error'] ?? 'oauth_error';
if (is_array($error)) {
$error = (string) ($error['message'] ?? $error['status'] ?? 'oauth_error');
} else {
$error = (string) $error;
}
Log::warning('AdSense OAuth token request rejected.', AdsenseLogSanitizer::context([
'status' => $response->status(),
'error' => $error,
]));
throw new AdsenseOAuthException($error, $response->status());
}
$accessToken = (string) ($json['access_token'] ?? '');
if ($accessToken === '') {
throw new AdsenseOAuthException('Google did not return an access token.');
}
$refreshToken = $json['refresh_token'] ?? null;
return [
'access_token' => $accessToken,
'refresh_token' => is_string($refreshToken) && $refreshToken !== '' ? $refreshToken : null,
'expires_in' => (int) ($json['expires_in'] ?? 3600),
];
}
private function isInvalidGrant(AdsenseOAuthException $exception): bool
{
$message = Str::lower($exception->getMessage());
return str_contains($message, 'invalid_grant')
|| str_contains($message, 'revoked')
|| str_contains($message, 'invalid_token');
}
private function cacheKey(int $connectionId): string
{
return (string) config('adsense.access_token_cache_prefix', 'adsense.access_token.').$connectionId;
}
private function clientId(): string
{
return trim((string) config('adsense.client_id'));
}
private function clientSecret(): string
{
return trim((string) config('adsense.client_secret'));
}
public function redirectUri(): string
{
return trim((string) config('adsense.redirect_uri'));
}
}