patreon-8.x-2.x-dev/src/PatreonService.php
src/PatreonService.php
<?php
namespace Drupal\patreon;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Config\Config;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityTypeManager;
use Drupal\Core\Link;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Path\CurrentPathStack;
use Drupal\Core\State\StateInterface;
use Drupal\Core\Url;
use Drupal\Core\Routing\TrustedRedirectResponse;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use HansPeterOrding\OAuth2\Client\Provider\Patreon;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Token\AccessToken;
use League\OAuth2\Client\Token\AccessTokenInterface;
use Patreon\API;
use Drupal\user\UserInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Service to connect to the Patreon API.
*
* @package Drupal\patreon
*/
class PatreonService implements PatreonServiceInterface {
use StringTranslationTrait;
/**
* Config for the service.
*
* @var \Drupal\Core\Config\Config
*/
protected Config $config;
/**
* Watchdog logger channel for captcha.
*
* @var \Psr\Log\LoggerInterface
*/
protected LoggerInterface $logger;
/**
* Constructs a ParagraphsTypeIconUuidLookup instance.
*
* @param \Drupal\Core\Path\CurrentPathStack $path
* The Drupal Path service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* A Drupal Config Factory.
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger
* A logger channel.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger service.
* @param \Drupal\Core\Entity\EntityTypeManager $entityTypeManager
* An Entity Type Manager.
* @param \Symfony\Component\HttpFoundation\RequestStack $stack
* The request stack service.
* @param \Drupal\Core\State\StateInterface $stateApi
* A state service.
*/
public function __construct(
protected readonly CurrentPathStack $path,
protected readonly ConfigFactoryInterface $configFactory,
LoggerChannelFactoryInterface $logger,
protected readonly MessengerInterface $messenger,
protected readonly EntityTypeManager $entityTypeManager,
protected readonly RequestStack $stack,
protected readonly StateInterface $stateApi,
) {
$this->config = $this->configFactory->getEditable('patreon.settings');
$this->logger = $logger->get('patreon');
}
/**
* Helper to return the current scopes.
*
* @deprecated in patreon:4.2.0 and is removed from patreon:4.3.0. The module
* no longer supports custom scopes.
* @see https://www.drupal.org/project/patreon/issues/3083491
*/
public function getScopes(): array {
return [];
}
/**
* Helper to set the current scopes.
*
* @param array $scopes
* An array of API scopes.
*
* @return string[]
* An empty array.
*
* @deprecated in patreon:4.2.0 and is removed from patreon:4.3.0. The module
* no longer supports custom scopes.
* @see https://www.drupal.org/project/patreon/issues/3083491
*/
public function setScopes(array $scopes = []): array {
return [];
}
/**
* Function to get the supplied token.
*
* @return string
* Returns the stored token.
*
* @throws \Drupal\patreon\PatreonGeneralException
* @throws \Drupal\patreon\PatreonMissingTokenException
* @throws \Drupal\patreon\PatreonUnauthorizedException
*/
public function getToken(): string {
if ($tokens = $this->getStoredTokens()) {
if ($tokens->hasExpired()) {
$tokens = $this->getRefreshedTokens($tokens);
}
if ($tokens) {
return $tokens->getToken();
}
}
throw new PatreonMissingTokenException('An API token has not been set.');
}
/**
* Function to get the supplied refresh token.
*
* @return null
* Returns a null value.
*
* @deprecated in patreon:4.2.0 and is removed from patreon:4.3.0. Refresh
* tokens can be obtained from the AccessTokenInterface returned by
* $this->>getToken() can provide a refresh token if required.
* @see https://www.drupal.org/project/patreon/issues/3083491
*/
public function getRefreshToken(): null {
return NULL;
}
/**
* {@inheritdoc}
*/
public function getCallback(): Url {
return Url::fromRoute('patreon.patreon_controller_oauth_callback', [], [
'absolute' => TRUE,
]);
}
/**
* {@inheritdoc}
*/
public function authoriseAccount(bool $redirect = TRUE): TrustedRedirectResponse|bool|Url|null {
$return = NULL;
if ($oauth = $this->getOauth()) {
// Get the Authorization URL now so the state is set for later.
$return = Url::fromUri($oauth->getAuthorizationUrl());
// Get the state store it to the session.
$session = $this->stack->getCurrentRequest()->getSession();
$session->set('oauth2state', $oauth->getState());
if ($redirect) {
$return = new TrustedRedirectResponse($return->toString());
}
}
return $return;
}
/**
* Deprecated function.
*
* @param string $clientId
* The client id.
* @param string $redirectUrl
* The redirect URL.
* @param array $scopes
* The scopes.
* @param string $returnUrl
* The return URL.
*
* @return bool
* Returns FALSE.
*
* @deprecated in patreon:4.2.0 and is removed from patreon:4.3.0.
* Authorisation URL is now handled by the Oauth provider returned by
* $this->>getOauth().
* @see https://www.drupal.org/project/patreon/issues/3083491
*/
public function getAuthoriseUrl(string $clientId, string $redirectUrl, array $scopes, string $returnUrl = ''): bool {
return FALSE;
}
/**
* Helper to get a Url Object from a path.
*
* @deprecated in patreon:4.2.0 and is removed from patreon:4.3.0.
* The API return URL no longer contains coded values
* @see https://www.drupal.org/project/patreon/issues/3083491
*/
public function decodeState(string $state): bool {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function getOauth(): ?Patreon {
$return = NULL;
try {
$key = $this->config->get('patreon_client_id');
$secret = $this->config->get('patreon_client_secret');
if (!$key || !$secret) {
throw new PatreonMissingTokenException('No client details set.');
}
$return = new Patreon([
'clientId' => $key,
'clientSecret' => $secret,
'redirectUri' => $this->getCallback()->toString(),
]);
}
catch (\Exception $e) {
$this->logger->error($this->t('Error obtaining new Oauth - :error', [
':error' => $e->getMessage(),
]));
}
return $return;
}
/**
* {@inheritdoc}
*/
public function tokensFromCode(string $code): AccessTokenInterface {
try {
$oauth = $this->getOauth();
$tokens = $oauth->getAccessToken('authorization_code', [
'code' => $code,
]);
}
catch (IdentityProviderException $e) {
throw new PatreonUnauthorizedException($e->getMessage());
}
catch (\Exception $e) {
throw new PatreonGeneralException($e->getMessage());
}
return $tokens;
}
/**
* {@inheritdoc}
*/
public function storeTokens(AccessTokenInterface $tokens, ?UserInterface $account = NULL): void {
$this->stateApi->set('patreon.access_token', Json::encode($tokens));
}
/**
* {@inheritdoc}
*/
public function getStoredTokens(?UserInterface $account = NULL) :?AccessTokenInterface {
$return = NULL;
if ($json = $this->stateApi->get('patreon.access_token')) {
if ($values = Json::decode($json)) {
try {
$return = new AccessToken($values);
}
catch (\Exception $e) {
$this->logger->error($this->t('Error loading stored tokens - :error', [
':error' => $e->getMessage(),
]));
return NULL;
}
}
}
return $return;
}
/**
* {@inheritdoc}
*/
public function getRefreshedTokens(AccessTokenInterface $tokens): ?AccessTokenInterface {
if ($oauth = $this->getOauth()) {
try {
if ($refreshed = $oauth->getAccessToken('refresh_token', [
'refresh_token' => $tokens->getRefreshToken(),
])) {
$this->storeTokens($refreshed);
return $refreshed;
}
}
catch (\Exception $e) {
$this->logger->error($this->t('Error refreshing tokens - :error', [
':error' => $e->getMessage(),
]));
$this->messenger->addError($this->t('Your access tokens could not be refreshed: please reauthorise your account and try again.'));
}
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function getValueByKey(array $array, array $parents): mixed {
$nested = new NestedArray();
return $nested->getValue($array, $parents);
}
/**
* {@inheritdoc}
*/
public function fetchUser(): ?array {
return $this->apiFetch('fetch_user');
}
/**
* {@inheritdoc}
*/
public function fetchCampaign(): ?array {
return $this->apiFetch('fetch_campaigns');
}
/**
* Helper to fetch Campaign Details.
*
* @param string $campaign_id
* A Patreon Campaign ID.
*
* @return array|null
* An array of data from the API or false on error.
*/
public function fetchCampaignDetails(string $campaign_id): ?array {
return $this->apiFetch('fetch_campaign_details', [$campaign_id]);
}
/**
* {@inheritdoc}
*/
public function fetchPagePledges($campaign_id, $page_size, $cursor = NULL): ?array {
return $this->apiFetch('fetch_page_of_members_from_campaign', [
$campaign_id,
$page_size,
$cursor,
]);
}
/**
* Helper to fetch membership details for an id.
*
* @param string $member_id
* A Patreon membership id.
*
* @return array|null
* An array of data or NULL on error.
*/
public function fetchMemberDetails(string $member_id): ?array {
return $this->apiFetch('fetch_member_details', [$member_id]);
}
/**
* Helper function to query the Patreon API.
*
* @param string $function
* A valid Patreon API function.
* @param array $parameters
* An array of parameters required for the function call. Defaults to empty.
*
* @return null|array
* An array of the function callback data, or NULL on error.
*/
private function apiFetch(string $function, array $parameters = []): ?array {
$return = NULL;
try {
$client = new API($this->getToken());
if (method_exists($client, $function)) {
if ($parameters) {
// Only one of our methods has 3 parameters: all others can be called
// this way.
if (count($parameters) < 3) {
$api_response = $client->{$function}($parameters[0]);
}
else {
[$campaign_id, $page_size, $cursor] = $parameters;
$api_response = $client->{$function}($campaign_id, $page_size, $cursor);
}
}
else {
$api_response = $client->{$function}();
}
if (!empty($api_response)) {
if (is_string($api_response)) {
$api_response = Json::decode($api_response);
}
if ($error = $this->getValueByKey($api_response, ['errors', '0'])) {
if (isset($error['status']) && $error['status'] == '401') {
throw new PatreonUnauthorizedException('The Patreon API has returned an authorized response.');
}
else {
throw new PatreonGeneralException('Patreon API has returned an unknown response.');
}
}
else {
$return = $api_response;
}
}
else {
throw new PatreonGeneralException('Patreon API has returned an unknown response.');
}
}
}
catch (PatreonMissingTokenException $e) {
$this->logger->error($this->t('The Patreon API returned the following error: :error', [
':error' => $e->getMessage(),
]));
$this->messenger->addError($this->t('A valid API token has not been set. Please visit @link', [
'@link' => Url::fromRoute('patreon.settings_form')->toString(),
]));
}
catch (PatreonUnauthorizedException $e) {
$this->logger->error($this->t('The Patreon API returned the following error: :error', [
':error' => $e->getMessage(),
]));
$this->messenger->addError($this->t('Your API token has expired or not been set. Please visit @link', [
'@link' => Url::fromRoute('patreon.settings_form')->toString(),
]));
}
catch (PatreonGeneralException $e) {
$message = $this->t('The Patreon API returned the following error: :error', [
':error' => $e->getMessage(),
]);
$this->logger->error($message);
$this->messenger->addError($message);
}
return $return;
}
/**
* Deprecated function.
*
* @param string $function
* The function call that failed.
* @param array $parameters
* Any parameters for that call.
*
* @return bool
* The returned API data or FALSE on error.
*
* @deprecated in patreon:4.2.0 and is removed from patreon:4.3.0.
* If retry management is required, it should be handled in your own custom
* code.
* @see https://www.drupal.org/project/patreon/issues/3083491
*/
public function retry(string $function, array $parameters): bool {
return FALSE;
}
/**
* Helper to get tier data from a membership array.
*
* @param array $membership
* A return from fetchMembership().
*
* @return array
* An array of tier data: id => attributes.
*/
public function getTierData(array $membership): array {
$return = [];
if (isset($membership['included'])) {
foreach ($membership['included'] as $included) {
if ($included['type'] == 'tier') {
// The API does not currently return attributes so this will always be
// empty.
$return[$included['id']] = $included['attributes'];
}
}
}
return $return;
}
/**
* Helper to create Drupal roles from Patreon reward types.
*/
public function createRoles(): array {
$config_data = [];
if ($campaigns = $this->fetchCampaign()) {
$this->storeCampaigns($campaigns);
}
$storage = $this->entityTypeManager->getStorage('user_role');
$roles = $this->getPatreonRoleNames($campaigns);
$all = array_map(function ($item) {
return $item->label();
}, $storage->loadMultiple());
foreach ($roles as $label => $patreon_id) {
$id = strtolower(str_replace(' ', '_', $label));
if (!in_array($label, $all)) {
$data = [
'id' => $id,
'label' => $label,
];
$role = $storage->create($data);
$role->save();
}
$key = ($patreon_id) ?: $id;
$config_data[$key] = $id;
}
$this->stateApi->set('patreon.user_roles', $config_data);
return $config_data;
}
/**
* Helper to make all campaigns into Drupal roles.
*
* @param array|null $campaigns
* A return campaign endpoint.
*
* @return array
* An array of reward titles plus default roles.
*/
public function getPatreonRoleNames(?array $campaigns = NULL): array {
$roles = [
'Patreon User' => NULL,
'Deleted Patreon User' => NULL,
];
if ($campaigns && $campaign_data = $this->getValueByKey($campaigns, ['data'])) {
foreach ($campaign_data as $campaign) {
if ($details = $this->fetchCampaignDetails($campaign['id'])) {
if (isset($details['included'])) {
foreach ($details['included'] as $reward) {
if ($reward['type'] == 'tier') {
// The Patreon API PHP library does not allow us to fetch fields
// from included data so we can no longer get the tier label.
// Roles will have to be given the tier id instead.
$roles[$reward['id'] . ' Patron'] = $reward['id'];
}
}
}
}
}
}
return $roles;
}
/**
* Helper to store a list of a users campaigns.
*
* @param array $campaigns
* An array of data from ->fetchCampaigns or empty to recall.
*/
public function storeCampaigns(array $campaigns = []): void {
if (empty($campaigns)) {
$campaigns = $this->fetchCampaign();
}
$store = [];
if ($campaigns && $campaign_data = $this->getValueByKey($campaigns, ['data'])) {
foreach ($campaign_data as $campaign) {
$store[] = $campaign['id'];
}
}
$this->stateApi->set('patreon.campaigns', $store);
}
/**
* Create a link to sign users up to Patreon.
*
* @param int $minimum
* The minimum pledge amount.
* @param bool $log_in
* Whether to create an account for the user or not.
*
* @return \Drupal\Core\Link
* A link object.
*/
public function getSignupLink(int $minimum = 0, bool $log_in = FALSE): Link {
$redirect_url = ($log_in) ? $this->getCallback()->toString() : $this->stack->getCurrentRequest()->getSchemeAndHttpHost();
$state = Json::encode([
'final_page' => $this->path->getPath(),
]);
$url = Url::fromUri('https://www.patreon.com/oauth2/become-patron', [
'query' => [
'response_type' => 'code',
'min_cents' => $minimum,
'client_id' => $this->config->get('patreon_client_id'),
'scope' => UrlHelper::encodePath('identity identity[email] identity.memberships campaigns.members'),
'redirect_uri' => $redirect_url,
'state' => UrlHelper::encodePath(base64_encode($state)),
],
]);
return Link::fromTextAndUrl($this->t('Become a Patron'), $url);
}
}
