turnstile

Drupal paywall plugin
Log | Files | Refs | README | LICENSE

commit c61b2eaef559777e56aa7aec9a1ea7e20dc2b1c8
parent 2e05c96a07d702afa9dab1cf3340c35c93b4bf53
Author: Christian Grothoff <christian@grothoff.org>
Date:   Sun, 19 Oct 2025 15:30:24 +0200

move subscription price configuration into a separate form

Diffstat:
Asrc/Form/SubscriptionPricesForm.php | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/Form/TurnstileSettingsForm.php | 260+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mturnstile.links.menu.yml | 15+++++++++++----
Mturnstile.module | 44+++++++++++++++++++++++++++++++++++++++++---
Mturnstile.routing.yml | 17++++++++++++++---
5 files changed, 422 insertions(+), 102 deletions(-)

diff --git a/src/Form/SubscriptionPricesForm.php b/src/Form/SubscriptionPricesForm.php @@ -0,0 +1,187 @@ +<?php + +namespace Drupal\turnstile\Form; + +use Drupal\Core\Form\ConfigFormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Url; +use Drupal\turnstile\TalerMerchantApiService; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Configure subscription prices. + */ +class SubscriptionPricesForm extends ConfigFormBase { + + /** + * The Taler Merchant API service. + * + * @var \Drupal\turnstile\TalerMerchantApiService + */ + protected $apiService; + + /** + * Constructs a SubscriptionPricesForm object. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The factory for configuration objects. + * @param \Drupal\turnstile\TalerMerchantApiService $api_service + * The API service. + */ + public function __construct(ConfigFactoryInterface $config_factory, TalerMerchantApiService $api_service) { + parent::__construct($config_factory); + $this->apiService = $api_service; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory'), + $container->get('turnstile.api_service') + ); + } + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames() { + return ['turnstile.settings']; + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'turnstile_subscription_prices_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $config = $this->config('turnstile.settings'); + + // Check if backend is configured + $backend_url = $config->get('payment_backend_url'); + $access_token = $config->get('access_token'); + + if (empty($backend_url) || empty($access_token)) { + $this->messenger()->addError( + $this->t('Payment backend is not configured. Please <a href="@url">configure the backend</a> first.', [ + '@url' => Url::fromRoute('turnstile.settings')->toString(), + ]) + ); + return $form; + } + + $form['description'] = [ + '#type' => 'item', + '#markup' => $this->t('<p>Set the price for buying each subscription type in different currencies. Leave a field empty to prevent users from buying that subscription with that currency.</p>'), + ]; + + // Get subscriptions and currencies from API. + $subscriptions = $this->apiService->getSubscriptions(); + $currencies = $this->apiService->getCurrencies(); + + if (empty($currencies)) { + $this->messenger()->addError($this->t('Unable to load currencies from the API. Please check your backend configuration.')); + return $form; + } + + if (empty($subscriptions)) { + $this->messenger()->addWarning($this->t('No subscriptions configured in Taler merchant backend.')); + return $form; + } + + $form['subscription_prices'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Subscription Prices'), + '#description' => $this->t('Set the price for buying each subscription type in different currencies.'), + '#tree' => TRUE, + ]; + + $existing_prices = $config->get('subscription_prices') ?: []; + + foreach ($subscriptions as $subscription_id => $subscription) { + $subscription_label = $subscription['label'] ?? $subscription['name']; + + // Skip the %none% case as you can't buy "no subscription" + if ($subscription_id === '%none%') { + continue; + } + + $form['subscription_prices'][$subscription_id] = [ + '#type' => 'details', + '#title' => $subscription_label, + '#description' => $subscription['description'] ?? '', + '#open' => TRUE, + ]; + + foreach ($currencies as $currency) { + $currency_code = $currency['code']; + $currency_label = $currency['label'] ?? $currency['code']; + + $form['subscription_prices'][$subscription_id][$currency_code] = [ + '#type' => 'number', + '#title' => $currency_label, + '#default_value' => $existing_prices[$subscription_id][$currency_code] ?? '', + '#min' => 0, + '#step' => $currency['step'] ?? 0.01, + '#size' => 20, + '#description' => $this->t('Leave empty to prevent buying this subscription with @currency.', [ + '@currency' => $currency_code, + ]), + ]; + } + } + + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + parent::validateForm($form, $form_state); + + // Validate subscription prices + $subscription_prices = $form_state->getValue('subscription_prices'); + if (is_array($subscription_prices)) { + foreach ($subscription_prices as $subscription_id => $currencies) { + if (is_array($currencies)) { + foreach ($currencies as $currency_code => $price) { + // Skip empty values as they are allowed + if ($price === '' || $price === NULL) { + continue; + } + + // Validate that the price is a valid non-negative number + if (!is_numeric($price) || $price < 0) { + $form_state->setErrorByName( + "subscription_prices][{$subscription_id}][{$currency_code}", + $this->t('Subscription prices must be non-negative numbers.') + ); + } + } + } + } + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $subscription_prices = $form_state->getValue('subscription_prices'); + + $this->config('turnstile.settings') + ->set('subscription_prices', $subscription_prices) + ->save(); + + parent::submitForm($form, $form_state); + } + +} +\ No newline at end of file diff --git a/src/Form/TurnstileSettingsForm.php b/src/Form/TurnstileSettingsForm.php @@ -39,7 +39,7 @@ class TurnstileSettingsForm extends ConfigFormBase { * * @var \Drupal\turnstile\TalerMerchantApiService */ - protected $apiService; + protected $apiService; // FIXME: now dead, but maybe move /config and /private/orders requests into here? /** * Constructs a TurnstileSettingsForm object. @@ -130,6 +130,7 @@ class TurnstileSettingsForm extends ConfigFormBase { '#default_value' => $config->get('grant_access_on_error') ?: '', ]; +if (FALSE) { // Get subscriptions and currencies from API. $subscriptions = $this->apiService->getSubscriptions(); $currencies = $this->apiService->getCurrencies(); @@ -177,16 +178,128 @@ class TurnstileSettingsForm extends ConfigFormBase { } } + + if ($this->isBackendConfigured()) { + $form['subscription_prices_link'] = [ + '#type' => 'item', + '#title' => $this->t('Subscription Prices'), + '#markup' => $this->t('<p><a href="@url" class="button">Configure Subscription Prices</a></p>', [ + '@url' => Url::fromRoute('turnstile.subscription_prices')->toString(), + ]), + ]; + } else { + $form['subscription_prices_link'] = [ + '#type' => 'item', + '#title' => $this->t('Subscription Prices'), + '#markup' => $this->t('<p>You must configure and save the payment backend settings above before configuring subscription prices.</p>'), + ]; + } +} + return parent::buildForm($form, $form_state); } /** + * Return the base URL of the backend (without instance!) + * + * @return string|null + * base URL, or NULL if the backend is unconfigured + */ + private function getBaseURL() { + $config = $this->config('turnstile.settings'); + $backend_url = $config->get('payment_backend_url'); + if (empty($backend_url)) { + return NULL; + } + if (!str_ends_with($backend_url, '/')) { + return NULL; + } + $parsed_url = parse_url($backend_url); + $path = $parsed_url['path'] ?? '/'; + $cleaned_path = preg_replace('#^/instances/[^/]+/?#', '/', $path); + $base = $parsed_url['scheme'] . '://' . $parsed_url['host']; + if (isset($parsed_url['port'])) { + $base .= ':' . $parsed_url['port']; + } + return $base . $cleaned_path; + } + + + /** + * Check if the base URL is properly configured. + * + * @return bool + * TRUE if backend is configured and accessible. + */ + private function isBaseURLConfigured() { + $base_url = $this->getBaseURL(); + + if (NULL === $base_url) { + return FALSE; + } + + try { + $client = \Drupal::httpClient(); + $response = $client->get($base_url . 'config', [ + 'allow_redirects' => TRUE, + 'http_errors' => FALSE, + 'timeout' => 5, + ]); + if ($response->getStatusCode() !== 200) { + return FALSE; + } + $body = json_decode($response->getBody(), TRUE); + return isset($body['name']) && $body['name'] === 'taler-merchant'; + } catch (\Exception $e) { + return FALSE; + } + } + + + /** + * Check if the backend is properly configured. + * + * @return int + * 200 or 204 if backend is configured and accessible, + * 0 on other error, otherwise HTTP status code indicating error + */ + private function isBackendConfigured() { + if (!$this->isBaseURLConfigured()) { + return 0; + } + + $config = $this->config('turnstile.settings'); + $backend_url = $config->get('payment_backend_url'); + $access_token = $config->get('access_token'); + + try { + $client = \Drupal::httpClient(); + $response = $client->get( + $payment_backend_url . 'private/orders?limit=1', + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + ], + 'allow_redirects' => TRUE, + 'http_errors' => FALSE, + 'timeout' => 5, // seconds + ] + ); + return $response->getStatusCode(); + } catch (\Exception $e) { + return 0; + } + } + + + /** * {@inheritdoc} */ public function validateForm(array &$form, FormStateInterface $form_state) { parent::validateForm($form, $form_state); +if (FALSE) { // Validate subscription prices $subscription_prices = $form_state->getValue('subscription_prices'); if (is_array($subscription_prices)) { @@ -209,13 +322,14 @@ class TurnstileSettingsForm extends ConfigFormBase { } } } +} // Test the access token and backend URL. $payment_backend_url = $form_state->getValue('payment_backend_url'); $access_token = $form_state->getValue('access_token'); if ( (!empty($payment_backend_url)) && - (! str_ends_with($payment_backend_url, '/')) ) + (!str_ends_with($payment_backend_url, '/')) ) { $form_state->setErrorByName('payment_backend_url', $this->t('Payment backend URL must end with a "/".')); @@ -224,7 +338,7 @@ class TurnstileSettingsForm extends ConfigFormBase { } if ( (!empty($access_token)) && - (! str_starts_with($access_token, 'secret-token:')) ) + (!str_starts_with($access_token, 'secret-token:')) ) { $form_state->setErrorByName('payment_backend_url'); $form_state->setErrorByName('access_token', @@ -232,99 +346,56 @@ class TurnstileSettingsForm extends ConfigFormBase { return; } - if (!empty($payment_backend_url)) { - $parsed_url = parse_url($payment_backend_url); - $path = $parsed_url['path']; - // Remove "instances/$INSTANCE_ID/" to get the base URL (if present) - $cleaned_path = preg_replace('#^/instances/[^/]+/?#', '/', $path); - $base = $parsed_url['scheme'] . '://' . $parsed_url['host']; - $base_url = $base . $cleaned_path; - - try { - $client = \Drupal::httpClient(); - $response = $client->get( - $base_url . 'config', - [ - 'allow_redirects' => TRUE, - 'http_errors' => FALSE, - 'timeout' => 5, // seconds - ]); - if ( ($response->getStatusCode() !== 200) || - (json_decode($response->getBody(), TRUE)['name'] - != 'taler-merchant') ) { - $form_state->setErrorByName('payment_backend_url', - $this->t('Invalid payment backend URL')); - $form_state->setErrorByName('access_token'); - return; - } - } - catch (\Exception $e) { - $form_state->setErrorByName('payment_backend_url', - $this->t('HTTP request failed:' . $e)); - $form_state->setErrorByName('access_token'); - return; - } + if (! $this->isBaseURLConfigure()) { + $form_state->setErrorByName('payment_backend_url', + $this->t('Invalid payment backend URL')); + $form_state->setErrorByName('access_token'); + return; } - if (!empty($payment_backend_url) && !empty($access_token)) { - try { - $client = \Drupal::httpClient(); - $response = $client->get( - $payment_backend_url . 'private/orders?limit=1', - [ - 'headers' => [ - 'Authorization' => 'Bearer ' . $access_token, - ], - 'allow_redirects' => TRUE, - 'http_errors' => FALSE, - 'timeout' => 5, // seconds - ] - ); - switch ($response->getStatusCode()) { - case 502: - $form_state->setErrorByName('payment_backend_url', - $this->t('Bad gateway (502) trying to access the merchant backend')); - $form_state->setErrorByName('access_token'); - return; - case 500: - $form_state->setErrorByName('payment_backend_url', - $this->t('Internal server error (500) of the merchant backend reported')); - $form_state->setErrorByName('access_token'); - return; - case 404: - $form_state->setErrorByName('payment_backend_url', - $this->t('The specified instance is unknown to the merchant backend')); - $form_state->setErrorByName('access_token'); - return; - case 403: - $form_state->setErrorByName('payment_backend_url'); - $form_state->setErrorByName('access_token', - $this->t('Access token not accepted by the merchant backend')); - return; - case 401: - $form_state->setErrorByName('payment_backend_url'); - $form_state->setErrorByName('access_token', - $this->t('Access token not accepted by the merchant backend')); - return; - case 204: - // Empty order list is OK - break; - case 200: - // Success is great - break; - default: - $form_state->setErrorByName('payment_backend_url', - $this->t('Unexpected response (' . $response->getStatusCode() . ') from merchant backend')); - $form_state->setErrorByName('access_token'); - return; - } - } - catch (\Exception $e) { + $http_status = $this->isBackendConfigured(); + switch ($http_status) { + case 502: $form_state->setErrorByName('payment_backend_url', - $this->t('HTTP request failed:' . $e)); + $this->t('Bad gateway (502) trying to access the merchant backend')); $form_state->setErrorByName('access_token'); return; - } - } + case 500: + $form_state->setErrorByName('payment_backend_url', + $this->t('Internal server error (500) of the merchant backend reported')); + $form_state->setErrorByName('access_token'); + return; + case 404: + $form_state->setErrorByName('payment_backend_url', + $this->t('The specified instance is unknown to the merchant backend')); + $form_state->setErrorByName('access_token'); + return; + case 403: + $form_state->setErrorByName('payment_backend_url'); + $form_state->setErrorByName('access_token', + $this->t('Access token not accepted by the merchant backend')); + return; + case 401: + $form_state->setErrorByName('payment_backend_url'); + $form_state->setErrorByName('access_token', + $this->t('Access token not accepted by the merchant backend')); + return; + case 204: + // Empty order list is OK + break; + case 200: + // Success is great + break; + case 0: + $form_state->setErrorByName('payment_backend_url', + $this->t('HTTP request failed')); + $form_state->setErrorByName('access_token'); + return; + default: + $form_state->setErrorByName('payment_backend_url', + $this->t('Unexpected response (' . $response->getStatusCode() . ') from merchant backend')); + $form_state->setErrorByName('access_token'); + return; + } // end switch on HTTP status // If the merchant backend is not configured at all, we allow the user // to save the settings. But, we warn them if they did not set @@ -371,14 +442,19 @@ class TurnstileSettingsForm extends ConfigFormBase { } $grant_access_on_error = $form_state->getValue('grant_access_on_error'); + +if (FALSE) { $subscription_prices = $form_state->getValue('subscription_prices'); +} // Save configuration. $config->set('enabled_content_types', $new_enabled_types); $config->set('payment_backend_url', $payment_backend_url); $config->set('access_token', $access_token); $config->set('grant_access_on_error', $grant_access_on_error); +if (FALSE) { $config->set('subscription_prices', $subscription_prices); +} $config->save(); parent::submitForm($form, $form_state); diff --git a/turnstile.links.menu.yml b/turnstile.links.menu.yml @@ -1,13 +1,20 @@ turnstile.settings: - title: 'Turnstile' - description: 'Configure Turnstile settings' + title: 'Turnstile basics' + description: 'Configure GNU Taler payment backend and paid content types.' parent: system.admin_config_system route_name: turnstile.settings + weight: 98 + +turnstile.subscription_prices: + title: 'Turnstile subscription prices' + description: 'Configure prices for Turnstile subscriptions.' + parent: system.admin_config_system + route_name: turnstile.subscription_prices weight: 99 turnstile.turnstile_price_category.collection: - title: 'Price categories' + title: 'Turnstile price categories' route_name: entity.turnstile_price_category.collection - description: 'Manage price categories for content.' + description: 'Manage price categories for Turnstile.' parent: system.admin_structure weight: 10 diff --git a/turnstile.module b/turnstile.module @@ -81,6 +81,14 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent return; } + $subscriptions = $price_category->getFullSubscriptions(); + foreach ($subscriptions as $subscription_id) { + if (_turnstile_is_subscriber ($subscription_id)) { + \Drupal::logger('turnstile')->debug('Subscriber detected, granting access.'); + return; + } + } + // Disable page cache, this page is personalized! \Drupal::service('page_cache_kill_switch')->trigger(); @@ -102,11 +110,18 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent if ($order_status && $order_status['paid']) { \Drupal::logger('turnstile')->debug('Order was paid, granting session access.'); _turnstile_grant_session_access($node_id); + if ($order_status['subscription_slug'] ?? FALSE) { + \Drupal::logger('turnstile')->debug('Subscription was purchased, granting subscription access.'); + $subscription_slug = $order_status['subscription_slug']; + $expiration = $order_status['subscription_expiration']; + _turnstile_grant_subscriber_access ($subscription_slug, $expiration); + } return; } if ($order_status && - ($order_status['order_expiration'] ?? 0) < time() + 30) { - // If order expired, ignore it! + ($order_status['order_expiration'] ?? 0) < time() + 60) { + // If order expired (or would expire in less than one minute, + // so too soon for the user to still pay it), then ignore it! $order_info = NULL; } if (!$order_status) @@ -155,7 +170,7 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent // Replace the build array with teaser content // Keep important metadata from original build (?) $build = [ - '#cache' => ['contexts' => ['url']], + '#cache' => ['contexts' => ['url']], '#weight' => $build['#weight'] ?? 0, ]; @@ -178,6 +193,29 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent /** + * Helper function to grant subscription access for this + * visitor to the given node ID until the given expiration time. + */ +function _turnstile_grant_subscriber_access($subscription_slug, $expiration) { + $session = \Drupal::request()->getSession(); + $access_data = $session->get('turnstile_subscriptions', []); + $access_data[$subscription_slug] = $expiration; + $session->set('turnstile_subscriptions', $access_data); +} + + +/** + * Helper function to check if this session is currently + * subscribed on the given type of subscription. + */ +function _turnstile_is_subscriber($subscription_slug) { + $session = \Drupal::request()->getSession(); + $access_data = $session->get('turnstile_subscriptions', []); + return ($access_data[$subscription_slug] ?? 0) < time(); +} + + +/** * Helper function to grant session access for this * visitor to the given node ID. */ diff --git a/turnstile.routing.yml b/turnstile.routing.yml @@ -2,7 +2,18 @@ turnstile.settings: path: '/admin/config/system/Turnstile' defaults: _form: '\Drupal\turnstile\Form\TurnstileSettingsForm' - _title: 'Turnstile Settings' + _title: 'Turnstile settings' + requirements: + _permission: 'administer Turnstile' + options: + _admin_route: TRUE + +# Route for editing subscription prices +turnstile.subscription_prices: + path: '/admin/config/system/turnstile/subscription-prices' + defaults: + _form: '\Drupal\turnstile\Form\SubscriptionPricesForm' + _title: 'Subscription prices' requirements: _permission: 'administer Turnstile' options: @@ -13,7 +24,7 @@ entity.turnstile_price_category.collection: path: '/admin/structure/price-categories' defaults: _entity_list: 'turnstile_price_category' - _title: 'Price Categories' + _title: 'Price categories' requirements: _permission: 'administer price categories' @@ -45,6 +56,6 @@ turnstile.debughook: path: '/turndebug' defaults: _controller: '\Drupal\turnstile\Controller\DebugController::content' - _title: 'Turnstile Debugger' + _title: 'Turnstile debugger' requirements: _permission: 'access content' \ No newline at end of file