turnstile

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

commit e5ca70c69d9db7221a149f25e3b9e8c73a49677e
parent b0dbf81fe56926fd8eadabf6e5af02f9486f6794
Author: Christian Grothoff <christian@grothoff.org>
Date:   Sun, 19 Oct 2025 16:06:33 +0200

update README.md

Diffstat:
MREADME.md | 74+++++++++++++++++++++++++++++++++++++++++++-------------------------------
Msrc/Form/SubscriptionPricesForm.php | 2+-
Msrc/Form/TurnstileSettingsForm.php | 115+++++++------------------------------------------------------------------------
Msrc/TalerMerchantApiService.php | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 148 insertions(+), 138 deletions(-)

diff --git a/README.md b/README.md @@ -1,8 +1,9 @@ # Turnstile -A Drupal module that asks user to subscribe or pay +A Drupal module that asks user to subscribe or pay using GNU Taler before granting access to nodes. + ## Features - Adds a "price category" field to configurable content types @@ -12,15 +13,17 @@ before granting access to nodes. - Integration with external GNU Taler merchant backend for payment processing - Admin interface for configuration + ## Installation 1. Download and extract the module to your `modules/custom/` directory 2a. Enable the module via Drush: `drush en turnstile`, or 2b. Enable via the Drupal admin interface at `/admin/modules` + ## Configuration -Navigate to `/admin/config/content/Turnstile` to configure: +1. Navigate to `/admin/config/content/Turnstile` to configure: - **Enabled Content Types**: Select which content types should have the price field and access restriction - **Payment Backend URL**: Taler merchant backend HTTP(S) URL of your payment backend service @@ -28,47 +31,47 @@ Navigate to `/admin/config/content/Turnstile` to configure: - **Enable access if backend is down**: Disables Turnstile if we cannot setup payments with the Taler merchant backend for any reason -## Usage +2. Make sure your Taler merchant backend is properly configured: +2a. Bank account added +2b. Legitimization as account owner with payment service provider is done +3. Configure one or more classes of subscriptions (optional) -1. Configure your merchant backend instance (bank account, currencies, - subscription types). -2. Configure merchant backend access in Turnstile settings. - You may want to set it so that if the merchant backend is down, - all articles will be available gratis - (that way, you do not annoy your subscribers if your payment - processing is down). -3. Define one or more price categories under "Structure" -4. Enable Turnstile for some content types (bundles) -5. Create or edit a node of an enabled content type -6. Set a price category in the respective field to enable paywall protection -7a. Users without the `payment_cookie` will see truncated content -7b. Users with the cookie will see the original content +Navigate to `/admin/config/system/turnstile/subscription-prices` to configure: -## TODO +- **Subscription prices**: Price for each type of subscription and currency + + +## Usage + +1. Define one or more price categories under `/admin/structure/price-categories`: +1a. Define a price in each currency that you accept +1b. Define a possibly discounted price (including 0) for subscribers +2. Create or edit a node of an enabled content type +3. Set a price category in the respective field to require payment +4. Earn money: +4a. Users that need to pay will see truncated content +4b. Users with the cookie will see the original full content -- actually get subscriptions to work -- test new logic! -- split settings dialog (main settings vs. subscription prices) -- REFACTOR: move HTTP logic from TurnstileSettingsForm to the API class?? -- LATER: use order expiration from merchant backend (with new v1.1 implementation) - instead of hard-coding 1 day! ## How it Works The module uses `hook_entity_view_alter()` to intercept node rendering and -calls the `hasAccess()` function to: +checks if the customer has been granted access. + +If they need to pay, we truncate the content body and add a link to +enable the user to pay. If the user did pay (or subscribe), we +display the original content -1. Check if the customer already paid, -2a. If not set, truncates the content body, - adds a link to enable the user to pay! -2b. If set, display the original content ## File Structure ``` turnstile/ ├── config/ -│ └── install/ -│ └── turnstile.settings.yml +│ ├── install/ +│ │ └── turnstile.settings.yml - default configuration values +│ └── schema/ +│ └── turnstile.schema.yml - configuration schema (partial, without subscription prices) ├── src/ │ ├── Entity/ │ │ └── TurnstilePriceCategory.php - Main entity class for price categories @@ -76,9 +79,10 @@ turnstile/ │ │ ├── PriceCategoryForm.php - Add/edit form handler │ │ ├── PriceCategoryDeleteForm.php - Delete confirmation form │ │ ├── SubscriptionPricesForm.php - Configure subscription prices -│ │ └── TurnstileSettingsForm.php +│ │ └── TurnstileSettingsForm.php - Configure basics of Turnstile │ ├── PriceCategoryListBuilder.php - Admin list page builder │ ├── TalerMerchantApiService.php - API service for merchant backend interaction +│ ├── TurnstileFieldManager.php - Manages price-category field injection │ └── Service/ │ └── Turnstile.php ├── turnstile.info.yml - Module metadata and dependencies @@ -98,6 +102,14 @@ turnstile/ - Drupal 9 or 10 - PHP 7.4 or higher + ## License -AGPLv3-or-later. +AGPLv3-or-later, see COPYING for the full license terms. + + +## TODO + +- actually *TEST* subscriptions (and everything else) +- LATER: use order expiration from merchant backend (with new v1.1 implementation) + instead of hard-coding 1 day! diff --git a/src/Form/SubscriptionPricesForm.php b/src/Form/SubscriptionPricesForm.php @@ -70,7 +70,7 @@ class SubscriptionPricesForm extends ConfigFormBase { 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.', [ + $this->t('Turnstile payment backend is not configured. Please <a href="@url">configure the backend</a> first.', [ '@url' => Url::fromRoute('turnstile.settings')->toString(), ]) ); 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; // FIXME: now dead, but maybe move /config and /private/orders requests into here? + protected $apiService; /** * Constructs a TurnstileSettingsForm object. @@ -135,105 +135,10 @@ class TurnstileSettingsForm extends ConfigFormBase { /** - * 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); - - // Test the access token and backend URL. $payment_backend_url = $form_state->getValue('payment_backend_url'); $access_token = $form_state->getValue('access_token'); @@ -246,22 +151,22 @@ class TurnstileSettingsForm extends ConfigFormBase { return; } + if (! $this->apiService->checkConfig($payment_backend_url)) { + $form_state->setErrorByName('payment_backend_url', + $this->t('Invalid payment backend URL')); + $form_state->setErrorByName('access_token'); + return; + } + 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', $this->t('Access token must begin with a "secret-token:".')); return; } - if (! $this->isBaseURLConfigure()) { - $form_state->setErrorByName('payment_backend_url', - $this->t('Invalid payment backend URL')); - $form_state->setErrorByName('access_token'); - return; - } - $http_status = $this->isBackendConfigured(); + $http_status = $this->apiService->checkAccess ($payment_backend_url, $access_token); switch ($http_status) { case 502: $form_state->setErrorByName('payment_backend_url', diff --git a/src/TalerMerchantApiService.php b/src/TalerMerchantApiService.php @@ -70,6 +70,96 @@ class TalerMerchantApiService { $this->logger = $logger; } + + /** + * Return the base URL for the given backend URL (without instance!) + * + * @param string $backend_url + * Backend URL to check, may include '/instances/$ID' path + * @return string|null + * base URL, or NULL if the backend URL is invalid + */ + private function getBaseURL(string $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; + } + + + /** + * Checks if the given backend URL points to a Taler merchant backend. + * + * @param string $backend_url + * Backend URL to check, may include '/instances/$ID' path + * @return bool + * TRUE if this is a valid backend URL for a Taler backend + */ + public function checkConfig(string $backend_url) { + $base_url = $this->getBaseURL($backend_url); + if (NULL === $base_url) { + return FALSE; + } + try { + $http_client = $this->httpClientFactory->fromOptions([ + 'http_errors' => false, + 'allow_redirects' => TRUE, + 'timeout' => 5, // seconds + ]); + $response = $http_client->get($base_url . 'config'); + 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; + } + } + + + /** + * Checks if the given backend URL points to a Taler merchant backend. + * + * @param string $backend_url + * Backend URL to check, may include '/instances/$ID' path + * @param string $access_token + * Access token to talk to the instance + * @return int + * HTTP status from a plain GET to the order list, + * 200 or 204 if the backend is configured and accessible, + * 0 on other error, otherwise HTTP status code indicating the error + */ + public function checkAccess(string $backend_url, string $access_token) { + try { + $http_client = $this->httpClientFactory->fromOptions([ + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + ], + // Do not throw exceptions on 4xx/5xx status codes + 'http_errors' => false, + 'allow_redirects' => TRUE, + 'timeout' => 5, // seconds + ]); + $response = $http_client->get( + $payment_backend_url . 'private/orders?limit=1' + ); + return $response->getStatusCode(); + } catch (\Exception $e) { + return 0; + } + } + /** * Gets the list of available subscriptions. Always includes a special * entry for "No reduction" with ID "". @@ -107,10 +197,10 @@ class TalerMerchantApiService { $http_client = $this->httpClientFactory->fromOptions([ 'headers' => [ 'Authorization' => 'Bearer ' . $access_token, - 'Content-Type' => 'application/json', ], // Do not throw exceptions on 4xx/5xx status codes 'http_errors' => false, + 'allow_redirects' => TRUE, 'timeout' => 5, // seconds ]); $response = $http_client->get($backend_url . 'private/tokenfamilies'); @@ -192,6 +282,7 @@ class TalerMerchantApiService { $http_client = $this->httpClientFactory->fromOptions([ 'allow_redirects' => TRUE, 'http_errors' => FALSE, + 'allow_redirects' => TRUE, 'timeout' => 5, // seconds ]); @@ -270,6 +361,7 @@ class TalerMerchantApiService { ], // Do not throw exceptions on 4xx/5xx status codes 'http_errors' => false, + 'allow_redirects' => TRUE, 'timeout' => 5, // seconds ]); $response = $http_client->get($backend_url . 'private/orders/' . $order_id); @@ -475,6 +567,7 @@ class TalerMerchantApiService { ], // Do not throw exceptions on 4xx/5xx status codes 'http_errors' => false, + 'allow_redirects' => TRUE, 'timeout' => 5, // seconds ]); $response = $http_client->post($backend_url . 'private/orders', [