commit e5ca70c69d9db7221a149f25e3b9e8c73a49677e
parent b0dbf81fe56926fd8eadabf6e5af02f9486f6794
Author: Christian Grothoff <christian@grothoff.org>
Date: Sun, 19 Oct 2025 16:06:33 +0200
update README.md
Diffstat:
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', [