<?php
namespace PHPFUI\ConstantContact;
class Client
{
public string $accessToken = '';
public string $refreshToken = '';
private string $authorizeURL = 'https://authz.constantcontact.com/oauth2/default/v1/authorize';
private string $body = '';
private $guzzleFactory = null;
private \GuzzleHttp\HandlerStack $guzzleHandler;
private string $host = '';
private string $lastError = '';
private string $next = '';
private string $oauth2URL = 'https://authz.constantcontact.com/oauth2/default/v1/token';
private array $scopes = [];
private $sessionCallback = null;
private int $statusCode = 200;
private array $validScopes = ['account_read', 'account_update', 'contact_data', 'campaign_data', 'offline_access', ];
public function __construct(private string $clientAPIKey, private string $clientSecret, private string $redirectURI, public bool $PKCE = true)
{
$this->scopes = \array_flip($this->validScopes);
$this->host = $_SERVER['HTTP_HOST'] ?? '';
$this->guzzleHandler = \GuzzleHttp\HandlerStack::create();
$this->guzzleHandler->push(\Spatie\GuzzleRateLimiterMiddleware\RateLimiterMiddleware::perSecond(4));
}
public function acquireAccessToken(array $parameters) : bool
{
if (isset($parameters['error']))
{
$this->statusCode = 0;
$this->lastError = $parameters['error'] . ': ' . ($parameters['error_description'] ?? 'Undefined');
return false;
}
$expectedState = $this->session('PHPFUI\ConstantContact\state', null);
if (($parameters['state'] ?? 'undefined') != $expectedState)
{
$this->statusCode = 0;
$this->lastError = 'state is not correct';
return false;
}
$ch = \curl_init();
$params = [
'code' => $parameters['code'],
'redirect_uri' => $this->redirectURI,
'grant_type' => 'authorization_code',
];
if ($this->PKCE)
{
$params['code_verifier'] = $this->session('PHPFUI\ConstantContact\code_verifier', null);
}
$url = $this->oauth2URL . '?' . \http_build_query($params);
\curl_setopt($ch, CURLOPT_URL, $url);
\curl_setopt($ch, CURLOPT_POSTFIELDS, \json_encode(['client_id' => $this->clientAPIKey, 'client_secret' => $this->clientSecret, 'code' => $parameters['code']]));
$this->setAuthorization($ch);
\curl_setopt($ch, CURLOPT_POST, true);
return $this->exec($ch);
}
public function addScope(string $scope) : self
{
if (! \in_array($scope, $this->validScopes))
{
throw new \PHPFUI\ConstantContact\Exception\InvalidParameter("Scope {$scope} is not valid, must be one of (" . \implode(',', $this->validScopes) . ')');
}
$this->scopes[$scope] = true;
return $this;
}
public function delete(string $url) : ?array
{
try
{
$response = $this->getGuzzleClient()->request('DELETE', $url);
return $this->process($response);
}
catch (\GuzzleHttp\Exception\RequestException $e)
{
$this->lastError = $e->getMessage();
$this->statusCode = $e->getResponse()->getStatusCode();
}
return null;
}
public function get(string $url, array $parameters) : ?array
{
try
{
if ($parameters)
{
$paramString = \urldecode(\http_build_query($parameters));
$paramString = \preg_replace('/\[[0-9]\]/', '', $paramString);
if ($paramString)
{
$url .= '?' . $paramString;
}
}
$response = $this->getGuzzleClient()->request('GET', $url);
return $this->process($response);
}
catch (\GuzzleHttp\Exception\RequestException $e)
{
$this->lastError = $e->getMessage();
$this->statusCode = $e->getResponse()->getStatusCode();
}
return null;
}
public function getAuthorizationURL() : string
{
$scopes = \implode('+', \array_keys($this->scopes));
$state = \bin2hex(\random_bytes(8));
$this->session('PHPFUI\ConstantContact\state', $state);
$params = [
'response_type' => 'code',
'client_id' => $this->clientAPIKey,
'redirect_uri' => $this->redirectURI,
'scope' => $scopes,
'state' => $state,
];
if ($this->PKCE)
{
[$code_verifier, $code_challenge] = $this->codeChallenge();
$this->session('PHPFUI\ConstantContact\code_verifier', $code_verifier);
$params['code_challenge'] = $code_challenge;
$params['code_challenge_method'] = 'S256';
}
$url = $this->authorizeURL . '?' . \str_replace('%2B', '+', \http_build_query($params));
return $url;
}
public function getBody() : string
{
return $this->body;
}
public function getGuzzleClient(string $body = '', array $headers = []) : \GuzzleHttp\Client
{
$config = [
'headers' => $this->getHeaders($headers),
'handler' => $this->guzzleHandler, ];
if (\strlen($body))
{
$config['body'] = $body;
}
return $this->guzzleFactory ? \call_user_func($this->guzzleFactory, $config) : new \GuzzleHttp\Client($config);
}
public function getGuzzleFactory() : ?callable
{
return $this->guzzleFactory;
}
public function getLastError() : string
{
return $this->lastError;
}
public function getSessionCallback() : ?callable
{
return $this->sessionCallback;
}
public function getStatusCode() : int
{
return $this->statusCode;
}
public function next() : array
{
if (! $this->next)
{
return [];
}
$response = $this->getGuzzleClient()->request('GET', 'https://api.cc.email' . $this->next);
return $this->process($response);
}
public function patch(string $url, array $parameters) : ?array
{
return $this->put($url, $parameters, 'PATCH');
}
public function post(string $url, array $parameters) : ?array
{
try
{
$response = $this->getGuzzleClient(\json_encode($parameters['body'], JSON_PRETTY_PRINT))->request('POST', $url);
return $this->process($response);
}
catch (\GuzzleHttp\Exception\RequestException $e)
{
$this->lastError = $e->getMessage();
$this->statusCode = $e->getResponse()->getStatusCode();
}
return null;
}
public function put(string $url, array $parameters, string $method = 'PUT') : ?array
{
try
{
$json = \json_encode($parameters['body'], JSON_PRETTY_PRINT);
$guzzle = $this->getGuzzleClient(
$json,
[
'Connection' => 'keep-alive',
'Content-Length' => \strlen($json),
'Accept-Encoding' => 'gzip, deflate',
'Host' => $this->host,
'Accept' => '*/*'
]
);
$response = $guzzle->request($method, $url);
return $this->process($response);
}
catch (\GuzzleHttp\Exception\RequestException $e)
{
$this->lastError = $e->getMessage();
$this->statusCode = $e->getResponse()->getStatusCode();
}
return null;
}
public function refreshToken() : bool
{
$ch = \curl_init();
$params = [
'refresh_token' => $this->refreshToken,
'grant_type' => 'refresh_token',
'redirect_uri' => $this->redirectURI,
];
$url = $this->oauth2URL . '?' . \http_build_query($params);
\curl_setopt($ch, CURLOPT_URL, $url);
$this->setAuthorization($ch);
\curl_setopt($ch, CURLOPT_POST, true);
\curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
\curl_setopt($ch, CURLOPT_POSTFIELDS, '');
return $this->exec($ch);
}
public function removeScope(string $scope) : self
{
unset($this->scopes[$scope]);
return $this;
}
public function setGuzzleFactory(?callable $factory) : self
{
$this->guzzleFactory = $factory;
return $this;
}
public function setHost(string $host) : self
{
$this->host = $host;
return $this;
}
public function setScopes(array $scopes) : self
{
$this->scopes = [];
foreach ($scopes as $scope)
{
$this->addScope($scope);
}
return $this;
}
public function setSessionCallback(callable $callback) : self
{
$this->sessionCallback = $callback;
return $this;
}
private function base64url_encode(string $data) : string
{
return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '=');
}
private function codeChallenge(?string $code_verifier = null) : array
{
$gen = static function()
{
$strings = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
$length = \random_int(43, 128);
for ($i = 0; $i < $length; $i++)
{
yield $strings[\random_int(0, 65)];
}
};
$code = $code_verifier ?? \implode('', \iterator_to_array($gen()));
if (! \preg_match('/[A-Za-z0-9-._~]{43,128}/', $code))
{
return ['', ''];
}
return [$code, $this->base64url_encode(\pack('H*', \hash('sha256', $code)))];
}
private function exec(\CurlHandle $ch) : bool
{
\curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = \curl_exec($ch);
$this->lastError = '';
$this->statusCode = 0;
if ($result)
{
$data = \json_decode($result, true);
if (isset($data['error']))
{
$this->lastError = $data['error'] . ': ' . ($data['error_description'] ?? 'Undefined');
}
$this->accessToken = $data['access_token'] ?? '';
$this->refreshToken = $data['refresh_token'] ?? '';
return isset($data['access_token'], $data['refresh_token']);
}
$this->statusCode = \curl_errno($ch);
$this->lastError = \curl_error($ch);
return false;
}
private function getHeaders(array $additional = []) : array
{
return \array_merge([
'Cache-Control' => 'no-cache',
'Authorization' => 'Bearer ' . $this->accessToken,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
], $additional);
}
private function process(\Psr\Http\Message\ResponseInterface $response) : ?array
{
$this->next = '';
$this->lastError = $response->getReasonPhrase();
$this->statusCode = $response->getStatusCode();
$this->body = $response->getBody();
$data = \json_decode($this->body, true);
if (isset($data['_links']['next']['href']))
{
$this->next = $data['_links']['next']['href'];
}
if (null !== $data)
{
return $data;
}
$this->lastError = \json_last_error_msg();
$this->statusCode = \json_last_error();
return null;
}
private function session(string $key, ?string $value) : string
{
if ($this->sessionCallback)
{
return \call_user_func($this->sessionCallback, $key, $value);
}
if (PHP_SESSION_ACTIVE !== \session_status())
{
throw new \PHPFUI\ConstantContact\Exception('session not started. Call session_start() or use \PHPFUI\ConstantContact\Client->setSessionCallback');
}
if (null === $value)
{
$value = $_SESSION[$key] ?? '';
unset($_SESSION[$key]);
return $value;
}
return $_SESSION[$key] = $value;
}
private function setAuthorization(\CurlHandle $ch) : void
{
$auth = $this->clientAPIKey . ':' . $this->clientSecret;
$credentials = \base64_encode($auth);
$headers = ['Authorization: Basic ' . $credentials, 'cache-control: no-cache', ];
\curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
}