Copied!
<?php

namespace PHPFUI\ConstantContact;

/**
 * The Client class needs to store authentication information between PHP sessions in order to function correctly.  The class defaults to normal PHP $_SESSION handling,
 * but you can specify a callback via **setSessionCallback** to provide a different persistence model. The callback function signature is:
 *
 * @param string $key used to store or retrieve $value
 * @param ?string $value if null, value should be returned and key deleted, if not null, value should be stored by key.
 * @return string $value from store or value passed in on set (ignored)
 */
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', ];

	/**
	 * Construct a client.
	 *
	 * By default, all scopes are enabled.  You can remove any, or set new ones.
	 */
	public function __construct(private string $clientAPIKey, private string $clientSecret, private string $redirectURI, public bool $PKCE = true)
		{
		// default to all scopes
		$this->scopes = \array_flip($this->validScopes);
		$this->host = $_SERVER['HTTP_HOST'] ?? '';
		$this->guzzleHandler = \GuzzleHttp\HandlerStack::create();
		$this->guzzleHandler->push(\Spatie\GuzzleRateLimiterMiddleware\RateLimiterMiddleware::perSecond(4));
		}

	/**
	 * Exchange an authorization code for an access token.
	 *
	 * Make this call by passing in the code present when the account owner is redirected back to you.
	 * The response will contain an 'access_token' and 'refresh_token'
	 *
	 * @param array $parameters passed to redirect URL
	 */
	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;
			}

		// Use cURL to get access token and refresh token
		$ch = \curl_init();

		// Create full request URL
		$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);

		// Set method and to expect response
		\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;
		}

	/**
	 * Issue a delete request.  This is not normally called directly, but by the V3 namespace classes.
	 */
	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;
		}

	/**
	 * Issue a get request.  This is not normally called directly, but by the V3 namespace classes.
	 */
	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;
		}

	/**
	 * Generate the URL an account owner would use to allow your app
	 * to access their account.
	 *
	 * After visiting the URL, the account owner is prompted to log in and allow your app to access their account.
	 * They are then redirected to your redirect URL with the authorization code appended as a query parameter. e.g.:
	 * http://localhost:8888/?code={authorization_code}
	 */
	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();

			// Store generated random state and code challenge based on RFC 7636
			// https://datatracker.ietf.org/doc/html/rfc7636#section-6.1
			$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));	// hack %2B to + for stupid CC API bug

		return $url;
	}

	public function getBody() : string
		{
		return $this->body;
		}

	/**
	 * Return a new GuzzleHttp\Client with the appropriate headers and handlers set.
	 *
	 * If a Guzzle factory has been set, then the factory method will be called.
	 *
	 * @param array<string, int|string> $headers override the default headers
	 */
	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;
		}

	/**
	 * Get the next result set
	 *
	 * @return array of data, empty if no more results
	 */
	public function next() : array
		{
		if (! $this->next)
			{
			return [];
			}

		$response = $this->getGuzzleClient()->request('GET', 'https://api.cc.email' . $this->next);

		return $this->process($response);
		}

	/**
	 * Issue a patch request.  This is not normally called directly, but by the V3 namespace classes.
	 */
	public function patch(string $url, array $parameters) : ?array
		{
		return $this->put($url, $parameters, 'PATCH');
		}

	/**
	 * Issue a post request.  This is not normally called directly, but by the V3 namespace classes.
	 */
	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;
		}

	/**
	 * Issue a put request.  This is not normally called directly, but by the V3 namespace classes.
	 */
	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;
		}

	/**
	 * Refresh the access token.
	 */
	public function refreshToken() : bool
		{
		// Use cURL to get a new access token and refresh token
		$ch = \curl_init();

		// Create full request URL
		$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);

		// Set method and to expect response
		\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;
		}

	/**
	 * This library uses GuzzleHttp\Client to make CC API calls.  If you want to use your own GuzzleHttp\Client, you should create a factory callable method and set it.
	 *
	 * If the factory callable is set, it will be called with the appropriate $config array passed as the first parameter.
	 *
	 * Callback function signature:
	 *
	 *  - function(array $config) : \GuzzleHttp\Client
	 *
	 * @see [graham-campbell/guzzle-factory](https://packagist.org/packages/graham-campbell/guzzle-factory) for an example factory
	 */
	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;
		}

	/**
	 * To avoid using built in PHP Sessions, set the callback to save values yourself
	 *
	 * Callback function signature:
	 *
	 * - function(string $key, string $value) : string // returns string $value from store or value passed in on set (ignored)
	 */
	public function setSessionCallback(callable $callback) : self
		{
		$this->sessionCallback = $callback;

		return $this;
		}

	private function base64url_encode(string $data) : string
		{
		return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '=');
		}

	/**
	 * Generate code_verifier and code_challenge for rfc7636 PKCE.
	 * https://datatracker.ietf.org/doc/html/rfc7636#appendix-B
	 *
	 * @return array [code_verifier, code_challenge].
	 */
	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);

		// Make the call
		$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
		{
		// Set authorization header
		// Make string of "API_KEY:SECRET"
		$auth = $this->clientAPIKey . ':' . $this->clientSecret;
		// Base64 encode it
		$credentials = \base64_encode($auth);
		// Create and set the Authorization header to use the encoded credentials
		$headers = ['Authorization: Basic ' . $credentials, 'cache-control: no-cache', ];
		\curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
		}
	}
© 2026 Bruce Wells
Search Namespaces \ Classes
Configuration