turnstile

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

commit 2b4f74897e3f112dc73d40d695c237f057c348bd
parent d92a965a5e3e7d755726b78e89e3ad7fbb1a0946
Author: Christian Grothoff <christian@grothoff.org>
Date:   Mon, 20 Oct 2025 20:32:50 +0200

major rename fest

Diffstat:
MREADME.md | 36++++++++++++++++++------------------
Rconfig/install/turnstile.settings.yml -> config/install/taler_turnstile.settings.yml | 0
Aconfig/schema/taler_turnstile.schema.yml | 19+++++++++++++++++++
Dconfig/schema/turnstile.schema.yml | 19-------------------
Mjs/payment-button.js | 20++++++++------------
Msrc/Entity/TurnstilePriceCategory.php | 30+++++++++++++++---------------
Msrc/Form/PriceCategoryDeleteForm.php | 4++--
Msrc/Form/PriceCategoryForm.php | 12++++++------
Msrc/Form/SubscriptionPricesForm.php | 20++++++++++----------
Msrc/Form/TurnstileSettingsForm.php | 28++++++++++++++--------------
Msrc/PriceCategoryListBuilder.php | 4++--
Msrc/TalerMerchantApiService.php | 38+++++++++++++++++++-------------------
Msrc/TurnstileFieldManager.php | 34+++++++++++++++++-----------------
Ataler_turnstile.info.yml | 11+++++++++++
Ataler_turnstile.install | 42++++++++++++++++++++++++++++++++++++++++++
Ataler_turnstile.libraries.yml | 14++++++++++++++
Ataler_turnstile.links.action.yml | 7+++++++
Ataler_turnstile.links.menu.yml | 20++++++++++++++++++++
Ataler_turnstile.module | 290+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ataler_turnstile.permissions.yml | 9+++++++++
Ataler_turnstile.routing.yml | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Ataler_turnstile.services.yml | 12++++++++++++
Atemplates/taler-turnstile-payment-button.html.twig | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dtemplates/turnstile-payment-button.html.twig | 156-------------------------------------------------------------------------------
Dturnstile.info.yml | 11-----------
Dturnstile.install | 41-----------------------------------------
Dturnstile.libraries.yml | 14--------------
Dturnstile.links.action.yml | 7-------
Dturnstile.links.menu.yml | 20--------------------
Dturnstile.module | 290-------------------------------------------------------------------------------
Dturnstile.permissions.yml | 9---------
Dturnstile.routing.yml | 53-----------------------------------------------------
Dturnstile.services.yml | 12------------
33 files changed, 745 insertions(+), 747 deletions(-)

diff --git a/README.md b/README.md @@ -1,4 +1,4 @@ -# Turnstile +# GNU Taler Turnstile A Drupal module that asks user to subscribe or pay using GNU Taler before granting access to nodes. @@ -17,26 +17,26 @@ before granting access to nodes. ## Installation 1. Download and extract the module to your `modules/custom/` directory -2a. Enable the module via Drush: `drush en turnstile`, or +2a. Enable the module via Drush: `drush en taler_turnstile`, or 2b. Enable via the Drupal admin interface at `/admin/modules` ## Configuration -1. Navigate to `/admin/config/content/Turnstile` to configure: +1. Navigate to `/admin/config/content/taler-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 - **Access Token**: Authentication token for the Taler merchant backend -- **Enable access if backend is down**: Disables Turnstile if we cannot - setup payments with the Taler merchant backend for any reason +- **Enable access if backend is down**: Disables GNU Taler Turnstile if we + cannot setup payments with the Taler merchant backend for any reason 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) -Navigate to `/admin/config/system/turnstile/subscription-prices` to configure: +Navigate to `/admin/config/system/taler-turnstile/subscription-prices` to configure: - **Subscription prices**: Price for each type of subscription and currency @@ -66,12 +66,12 @@ display the original content ## File Structure ``` -turnstile/ +taler_turnstile/ ├── config/ │ ├── install/ -│ │ └── turnstile.settings.yml - default configuration values +│ │ └── taler_turnstile.settings.yml - default configuration values │ └── schema/ -│ └── turnstile.schema.yml - configuration schema (partial, without subscription prices) +│ └── taler_turnstile.schema.yml - configuration schema (partial, without subscription prices) ├── js/ │ ├── qrcode.min.js - QR code library from https://github.com/davidshimjs/qrcodejs │ └── payment-button.js - shows QR code and long-polls with backend for payment @@ -86,15 +86,15 @@ turnstile/ │ ├── PriceCategoryListBuilder.php - Admin list page builder │ ├── TalerMerchantApiService.php - API service for merchant backend interaction │ └── TurnstileFieldManager.php - Manages price-category field injection -├── turnstile.libraries.yml - JS libraries and dependencies -├── turnstile.info.yml - Module metadata and dependencies -├── turnstile.install - Install/uninstall hooks -├── turnstile.module - Hook implementations and debug functions -├── turnstile.permissions.yml - Permission definitions -├── turnstile.routing.yml - Route definitions for pages -├── turnstile.services.yml - Service container definitions -├── turnstile.links.menu.yml - Menu link to Structure menu -├── turnstile.links.action.yml - Action link for adding price categories +├── taler_turnstile.libraries.yml - JS libraries and dependencies +├── taler_turnstile.info.yml - Module metadata and dependencies +├── taler_turnstile.install - Install/uninstall hooks +├── taler_turnstile.module - Hook implementations and debug functions +├── taler_turnstile.permissions.yml - Permission definitions +├── taler_turnstile.routing.yml - Route definitions for pages +├── taler_turnstile.services.yml - Service container definitions +├── taler_turnstile.links.menu.yml - Menu link to Structure menu +├── taler_turnstile.links.action.yml - Action link for adding price categories └── README.md ``` diff --git a/config/install/turnstile.settings.yml b/config/install/taler_turnstile.settings.yml diff --git a/config/schema/taler_turnstile.schema.yml b/config/schema/taler_turnstile.schema.yml @@ -0,0 +1,19 @@ +taler_turnstile.settings: + type: config_object + label: 'GNU Taler Turnstile settings' + mapping: + enabled_content_types: + type: sequence + label: 'Enabled content types' + sequence: + type: string + label: 'Content type' + payment_backend_url: + type: string + label: 'Payment backend URL' + access_token: + type: string + label: 'Access token' + grant_access_on_error: + type: boolean + label: 'Disable paywall when payment backend is unavailable' diff --git a/config/schema/turnstile.schema.yml b/config/schema/turnstile.schema.yml @@ -1,19 +0,0 @@ -turnstile.settings: - type: config_object - label: 'Turnstile settings' - mapping: - enabled_content_types: - type: sequence - label: 'Enabled content types' - sequence: - type: string - label: 'Content type' - payment_backend_url: - type: string - label: 'Payment backend URL' - access_token: - type: string - label: 'Access token' - grant_access_on_error: - type: boolean - label: 'Disable paywall when payment backend is unavailable' diff --git a/js/payment-button.js b/js/payment-button.js @@ -1,6 +1,6 @@ /** * @file - * JavaScript for Turnstile payment button functionality. + * JavaScript for GNU Taler Turnstile payment button functionality. */ (function ($, Drupal, once) { @@ -126,17 +126,14 @@ /** * Attach payment button behavior. */ - Drupal.behaviors.turnstilePaymentButton = { + Drupal.behaviors.talerTurnstilePaymentButton = { attach: function (context, settings) { - var buttons = once('turnstile-payment', '.turnstile-pay-button', context); - buttons.forEach(function(button) { - var $button = $(button); - var paymentUrl = $button.attr('href'); - var sessionId = $button.data('session-id'); - - // Generate QR code - var $qrContainer = $('.turnstile-qr-code-container', context); - if ($qrContainer.length && paymentUrl && typeof QRCode !== 'undefined') { + var qrContainers = once('taler-turnstile-qr-generation', '.taler-turnstile-qr-code-container', context); + qrContainers.forEach(function(qrContainer) { + var $qrContainer = $(qrContainer); + var paymentUrl = $qrContainer.data('payment-url'); + var sessionId = $qrContainer.data('session-id'); + if (paymentUrl && typeof QRCode !== 'undefined') { $qrContainer.empty(); var talerUri = convertToTalerUri(paymentUrl, sessionId); new QRCode($qrContainer[0], { @@ -149,7 +146,6 @@ }); } - // Start polling for payment status if (paymentUrl) { console.log('Starting payment status polling for: ' + paymentUrl); pollPaymentStatus(paymentUrl, sessionId); diff --git a/src/Entity/TurnstilePriceCategory.php b/src/Entity/TurnstilePriceCategory.php @@ -2,10 +2,10 @@ /** * @file - * Price category structure for the turnstile module. + * Price category structure for the GNU Taler Turnstile module. */ -namespace Drupal\turnstile\Entity; +namespace Drupal\taler_turnstile\Entity; use Drupal\Core\Config\Entity\ConfigEntityBase; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -14,26 +14,26 @@ use Drupal\Core\StringTranslation\StringTranslationTrait; * Defines the Price Category entity. * * @ConfigEntityType( - * id = "turnstile_price_category", + * id = "taler_turnstile_price_category", * label = @Translation("Price Category"), * handlers = { - * "list_builder" = "Drupal\turnstile\PriceCategoryListBuilder", + * "list_builder" = "Drupal\taler_turnstile\PriceCategoryListBuilder", * "form" = { - * "add" = "Drupal\turnstile\Form\PriceCategoryForm", - * "edit" = "Drupal\turnstile\Form\PriceCategoryForm", - * "delete" = "Drupal\turnstile\Form\PriceCategoryDeleteForm" + * "add" = "Drupal\taler_turnstile\Form\PriceCategoryForm", + * "edit" = "Drupal\taler_turnstile\Form\PriceCategoryForm", + * "delete" = "Drupal\taler_turnstile\Form\PriceCategoryDeleteForm" * } * }, - * config_prefix = "turnstile_price_category", + * config_prefix = "taler_turnstile_price_category", * admin_permission = "administer price categories", * entity_keys = { * "id" = "id", * "label" = "label" * }, * links = { - * "collection" = "/admin/structure/price-categories", - * "edit-form" = "/admin/structure/price-categories/{price_category}/edit", - * "delete-form" = "/admin/structure/price-categories/{price_category}/delete" + * "collection" = "/admin/structure/taler-turnstile-price-categories", + * "edit-form" = "/admin/structure/taler-turnstile-price-categories/{price_category}/edit", + * "delete-form" = "/admin/structure/taler-turnstile-price-categories/{price_category}/delete" * }, * config_export = { * "id", @@ -132,7 +132,7 @@ class TurnstilePriceCategory extends ConfigEntityBase { * Structure suitable for the choices array in the v1 contract */ public function getPaymentChoices(): array { - $cid = 'turnstile:payment_choices:' . $this->id(); + $cid = 'taler_turnstile:payment_choices:' . $this->id(); if ($cache = \Drupal::cache()->get($cid)) { return $cache->data; } @@ -208,10 +208,10 @@ class TurnstilePriceCategory extends ConfigEntityBase { } // for each type of subscription - // This should return ['config:turnstile_price_category.' . $this->id()]; + // This should return ['config:taler_turnstile_price_category.' . $this->id()]; $tags = $this->getCacheTags(); // Invalidate cache if getSubscriptionPrice() changes - $tags[] = 'config:turnstile.settings'; + $tags[] = 'config:taler_turnstile.settings'; // Invalidate cache also when translations change $tags[] = 'locale'; \Drupal::cache()->set( @@ -249,7 +249,7 @@ class TurnstilePriceCategory extends ConfigEntityBase { * The subscription price (will map to a float), NULL on error */ private function getSubscriptionPrice (string $tokenFamilySlug, string $currencyCode) { - $config = \Drupal::config('turnstile.settings'); + $config = \Drupal::config('taler_turnstile.settings'); $subscriptions_prices = $config->get('subscription_prices') ?? []; $subscription_prices = $subscriptions_prices[$tokenFamilySlug] ?? []; $subscription_price = $subscription_prices[$currencyCode] ?? NULL; diff --git a/src/Form/PriceCategoryDeleteForm.php b/src/Form/PriceCategoryDeleteForm.php @@ -7,7 +7,7 @@ * Confirmation form for deleting a price category. */ -namespace Drupal\turnstile\Form; +namespace Drupal\taler_turnstile\Form; use Drupal\Core\Entity\EntityConfirmFormBase; use Drupal\Core\Form\FormStateInterface; @@ -31,7 +31,7 @@ class PriceCategoryDeleteForm extends EntityConfirmFormBase { * {@inheritdoc} */ public function getCancelUrl() { - return new Url('entity.turnstile_price_category.collection'); + return new Url('entity.taler_turnstile_price_category.collection'); } /** diff --git a/src/Form/PriceCategoryForm.php b/src/Form/PriceCategoryForm.php @@ -7,11 +7,11 @@ * Form handler for price category add and edit forms. */ -namespace Drupal\turnstile\Form; +namespace Drupal\taler_turnstile\Form; use Drupal\Core\Entity\EntityForm; use Drupal\Core\Form\FormStateInterface; -use Drupal\turnstile\TalerMerchantApiService; +use Drupal\taler_turnstile\TalerMerchantApiService; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -22,14 +22,14 @@ class PriceCategoryForm extends EntityForm { /** * The Turnstile API service. * - * @var \Drupal\turnstile\TalerMerchantApiService + * @var \Drupal\taler_turnstile\TalerMerchantApiService */ protected $apiService; /** * Constructs a PriceCategoryForm object. * - * @param \Drupal\turnstile\TalerMerchantApiService $api_service + * @param \Drupal\taler_turnstile\TalerMerchantApiService $api_service * The API service. */ public function __construct(TalerMerchantApiService $api_service) { @@ -41,7 +41,7 @@ class PriceCategoryForm extends EntityForm { */ public static function create(ContainerInterface $container) { return new static( - $container->get('turnstile.api_service') + $container->get('taler_turnstile.api_service') ); } @@ -66,7 +66,7 @@ class PriceCategoryForm extends EntityForm { '#type' => 'machine_name', '#default_value' => $price_category->id(), '#machine_name' => [ - 'exists' => '\Drupal\turnstile\Entity\TurnstilePriceCategory::load', + 'exists' => '\Drupal\taler_turnstile\Entity\TurnstilePriceCategory::load', ], '#disabled' => !$price_category->isNew(), ]; diff --git a/src/Form/SubscriptionPricesForm.php b/src/Form/SubscriptionPricesForm.php @@ -1,12 +1,12 @@ <?php -namespace Drupal\turnstile\Form; +namespace Drupal\taler_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 Drupal\taler_turnstile\TalerMerchantApiService; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -17,7 +17,7 @@ class SubscriptionPricesForm extends ConfigFormBase { /** * The Taler Merchant API service. * - * @var \Drupal\turnstile\TalerMerchantApiService + * @var \Drupal\taler_turnstile\TalerMerchantApiService */ protected $apiService; @@ -26,7 +26,7 @@ class SubscriptionPricesForm extends ConfigFormBase { * * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * The factory for configuration objects. - * @param \Drupal\turnstile\TalerMerchantApiService $api_service + * @param \Drupal\taler_turnstile\TalerMerchantApiService $api_service * The API service. */ public function __construct(ConfigFactoryInterface $config_factory, TalerMerchantApiService $api_service) { @@ -40,7 +40,7 @@ class SubscriptionPricesForm extends ConfigFormBase { public static function create(ContainerInterface $container) { return new static( $container->get('config.factory'), - $container->get('turnstile.api_service') + $container->get('taler_turnstile.api_service') ); } @@ -48,21 +48,21 @@ class SubscriptionPricesForm extends ConfigFormBase { * {@inheritdoc} */ protected function getEditableConfigNames() { - return ['turnstile.settings']; + return ['taler_turnstile.settings']; } /** * {@inheritdoc} */ public function getFormId() { - return 'turnstile_subscription_prices_form'; + return 'taler_turnstile_subscription_prices_form'; } /** * {@inheritdoc} */ public function buildForm(array $form, FormStateInterface $form_state) { - $config = $this->config('turnstile.settings'); + $config = $this->config('taler_turnstile.settings'); // Check if backend is configured $backend_url = $config->get('payment_backend_url'); @@ -71,7 +71,7 @@ class SubscriptionPricesForm extends ConfigFormBase { if (empty($backend_url) || empty($access_token)) { $this->messenger()->addError( $this->t('Turnstile payment backend is not configured. Please <a href="@url">configure the backend</a> first.', [ - '@url' => Url::fromRoute('turnstile.settings')->toString(), + '@url' => Url::fromRoute('taler_turnstile.settings')->toString(), ]) ); return parent::buildForm($form, $form_state); @@ -176,7 +176,7 @@ class SubscriptionPricesForm extends ConfigFormBase { * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { - $config = $this->config('turnstile.settings'); + $config = $this->config('taler_turnstile.settings'); $subscription_prices = $form_state->getValue('subscription_prices'); $config->set('subscription_prices', $subscription_prices); diff --git a/src/Form/TurnstileSettingsForm.php b/src/Form/TurnstileSettingsForm.php @@ -1,17 +1,17 @@ <?php -namespace Drupal\turnstile\Form; +namespace Drupal\taler_turnstile\Form; use Drupal\Core\Form\ConfigFormBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\turnstile\TurnstileFieldManager; -use Drupal\turnstile\TalerMerchantApiService; +use Drupal\taler_turnstile\TurnstileFieldManager; +use Drupal\taler_turnstile\TalerMerchantApiService; use Symfony\Component\DependencyInjection\ContainerInterface; /** - * Configure Turnstile settings. + * Configure GNU Taler Turnstile settings. */ class TurnstileSettingsForm extends ConfigFormBase { @@ -25,14 +25,14 @@ class TurnstileSettingsForm extends ConfigFormBase { /** * The Turnstile field manager. * - * @var \Drupal\turnstile\TurnstileFieldManager + * @var \Drupal\taler_turnstile\TurnstileFieldManager */ protected $fieldManager; /** * The Taler Merchant API service. * - * @var \Drupal\turnstile\TalerMerchantApiService + * @var \Drupal\taler_turnstile\TalerMerchantApiService */ protected $apiService; @@ -43,9 +43,9 @@ class TurnstileSettingsForm extends ConfigFormBase { * The factory for configuration objects. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. - * @param \Drupal\turnstile\TurnstileFieldManager $field_manager + * @param \Drupal\taler_turnstile\TurnstileFieldManager $field_manager * The field manager. - * @param \Drupal\turnstile\TalerMerchantApiService $api_service + * @param \Drupal\taler_turnstile\TalerMerchantApiService $api_service * The API service. */ public function __construct(ConfigFactoryInterface $config_factory, EntityTypeManagerInterface $entity_type_manager, TurnstileFieldManager $field_manager, TalerMerchantApiService $api_service) { @@ -62,8 +62,8 @@ class TurnstileSettingsForm extends ConfigFormBase { return new static( $container->get('config.factory'), $container->get('entity_type.manager'), - $container->get('turnstile.field_manager'), - $container->get('turnstile.api_service') + $container->get('taler_turnstile.field_manager'), + $container->get('taler_turnstile.api_service') ); } @@ -71,21 +71,21 @@ class TurnstileSettingsForm extends ConfigFormBase { * {@inheritdoc} */ protected function getEditableConfigNames() { - return ['turnstile.settings']; + return ['taler_turnstile.settings']; } /** * {@inheritdoc} */ public function getFormId() { - return 'turnstile_settings_form'; + return 'taler_turnstile_settings_form'; } /** * {@inheritdoc} */ public function buildForm(array $form, FormStateInterface $form_state) { - $config = $this->config('turnstile.settings'); + $config = $this->config('taler_turnstile.settings'); // Get all available content types. $content_types = $this->entityTypeManager->getStorage('node_type')->loadMultiple(); @@ -214,7 +214,7 @@ class TurnstileSettingsForm extends ConfigFormBase { * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { - $config = $this->config('turnstile.settings'); + $config = $this->config('taler_turnstile.settings'); // Test the access token and backend URL. $payment_backend_url = $form_state->getValue('payment_backend_url'); diff --git a/src/PriceCategoryListBuilder.php b/src/PriceCategoryListBuilder.php @@ -7,7 +7,7 @@ * List builder for price categories. */ -namespace Drupal\turnstile; +namespace Drupal\taler_turnstile; use Drupal\Core\Config\Entity\ConfigEntityListBuilder; use Drupal\Core\Entity\EntityInterface; @@ -31,7 +31,7 @@ class PriceCategoryListBuilder extends ConfigEntityListBuilder { * {@inheritdoc} */ public function buildRow(EntityInterface $entity) { - /** @var \Drupal\turnstile\Entity\TurnstilePriceCategory $entity */ + /** @var \Drupal\taler_turnstile\Entity\TurnstilePriceCategory $entity */ $row['label'] = $entity->label(); $row['id'] = $entity->id(); $row['description'] = $entity->getDescription(); diff --git a/src/TalerMerchantApiService.php b/src/TalerMerchantApiService.php @@ -7,12 +7,12 @@ * Service for interacting with the Taler Merchant Backend. */ -namespace Drupal\turnstile; +namespace Drupal\taler_turnstile; use Drupal\Core\Http\ClientFactory; use Drupal\node\NodeInterface; use Psr\Log\LoggerInterface; -use Drupal\turnstile\Entity\TurnstilePriceCategory; +use Drupal\taler_turnstile\Entity\TurnstilePriceCategory; use GuzzleHttp\Exception\RequestException; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -174,7 +174,7 @@ class TalerMerchantApiService { * Array mapping token family IDs to subscription data each with a 'name' and 'label' (usually the slug), 'description' and 'description_i18n'. */ public function getSubscriptions() { - $cid = 'turnstile:subscriptions'; + $cid = 'taler_turnstile:subscriptions'; if ($cache = \Drupal::cache()->get($cid)) { return $cache->data; } @@ -192,13 +192,13 @@ class TalerMerchantApiService { 'description' => $description, 'description_i18n' => $description_i18n, ]; - $config = \Drupal::config('turnstile.settings'); + $config = \Drupal::config('taler_turnstile.settings'); $backend_url = $config->get('payment_backend_url'); $access_token = $config->get('access_token'); if (empty($backend_url) || empty($access_token)) { - $this->logger->debug('No Turnstile backend configured, returning "none" for subscriptions.'); + $this->logger->debug('No GNU Taler Turnstile backend configured, returning "none" for subscriptions.'); return $result; } @@ -231,7 +231,7 @@ class TalerMerchantApiService { // empty list return $result; case 403: - $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your Turnstile configuration!'); + $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your GNU Taler Turnstile configuration!'); return $result; case 404: $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); @@ -274,12 +274,12 @@ class TalerMerchantApiService { * and 'step' (typically 0 for JPY or 0.01 for EUR/USD). */ public function getCurrencies() { - $cid = 'turnstile:currencies'; + $cid = 'taler_turnstile:currencies'; if ($cache = \Drupal::cache()->get($cid)) { return $cache->data; } - $config = \Drupal::config('turnstile.settings'); + $config = \Drupal::config('taler_turnstile.settings'); $payment_backend_url = $config->get('payment_backend_url'); if (empty($payment_backend_url)) { @@ -354,13 +354,13 @@ class TalerMerchantApiService { * Order status information or FALSE on failure. */ public function checkOrderStatus($order_id) { - $config = \Drupal::config('turnstile.settings'); + $config = \Drupal::config('taler_turnstile.settings'); $backend_url = $config->get('payment_backend_url'); $access_token = $config->get('access_token'); if (empty($backend_url) || empty($access_token)) { - $this->logger->debug('No Turnstile backend configured, cannot check order status!'); + $this->logger->debug('No GNU Taler Turnstile backend configured, cannot check order status!'); return FALSE; } @@ -387,7 +387,7 @@ class TalerMerchantApiService { // Success, handled below break; case 403: - $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your Turnstile configuration!'); + $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your GNU Taler Turnstile configuration!'); return FALSE; case 404: // Order unknown or instance unknown @@ -399,12 +399,12 @@ class TalerMerchantApiService { // Protocol violation. Could happen if the backend domain was // taken over by someone else. $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Invalid response from merchant backend when trying to obtain order status. Check your Turnstile configuration! @body', ['@body' => $body_log_fmt ?? 'N/A']); + $this->logger->error('Invalid response from merchant backend when trying to obtain order status. Check your GNU Taler Turnstile configuration! @body', ['@body' => $body_log_fmt ?? 'N/A']); return FALSE; case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN: // This could happen if our instance was deleted after the configuration was // checked. Very bad, log serious error. - $this->logger->error('Configured instance "@detail" unknown to merchant backend. Check your Turnstile configuration!', ['@detail' => $jbody['detail'] ?? 'N/A']); + $this->logger->error('Configured instance "@detail" unknown to merchant backend. Check your GNU Taler Turnstile configuration!', ['@detail' => $jbody['detail'] ?? 'N/A']); return FALSE; case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN: // This could happen if the instance owner manually deleted @@ -471,7 +471,7 @@ class TalerMerchantApiService { ($signature_validity_end > $now) ) { // Theoretically, one contract could buy multiple - // subscriptions. But Turnstile does not + // subscriptions. But GNU Taler Turnstile does not // generate such contracts and we do not support // that case here. $subscription_slug = $slug; @@ -519,7 +519,7 @@ class TalerMerchantApiService { * Order information or FALSE on failure. */ public function createOrder(NodeInterface $node) { - $config = \Drupal::config('turnstile.settings'); + $config = \Drupal::config('taler_turnstile.settings'); $backend_url = $config->get('payment_backend_url'); $access_token = $config->get('access_token'); @@ -529,7 +529,7 @@ class TalerMerchantApiService { } /** @var \Drupal\Core\Field\EntityReferenceFieldItemList $field */ - $field = $node->get('field_turnstile_price_category'); + $field = $node->get('field_taler_turnstile_prcat'); if ($field->isEmpty()) { $this->logger->debug('No price category selected'); return FALSE; @@ -601,7 +601,7 @@ class TalerMerchantApiService { // Success, handled below break; case 403: - $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your Turnstile configuration!'); + $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your GNU Taler Turnstile configuration!'); return FALSE; case 404: $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); @@ -609,8 +609,8 @@ class TalerMerchantApiService { return FALSE; case 409: case 410: - // 409: We didn't specify an order, so this should be "wrong currency", which again Turnstile tries to prevent. So this shouldn't be possible. - // 410: We didn't specify a product, so out-of-stock should also be impossible for Turnstile + // 409: We didn't specify an order, so this should be "wrong currency", which again GNU Taler Turnstile tries to prevent. So this shouldn't be possible. + // 410: We didn't specify a product, so out-of-stock should also be impossible for GNU Taler Turnstile $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); $this->logger->error('Unexpected HTTP status code @status trying to create order: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@detail' => $jbody['detail'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']); return FALSE; diff --git a/src/TurnstileFieldManager.php b/src/TurnstileFieldManager.php @@ -1,6 +1,6 @@ <?php -namespace Drupal\turnstile; +namespace Drupal\taler_turnstile; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\field\Entity\FieldStorageConfig; @@ -10,7 +10,7 @@ use Drupal\Core\Entity\Entity\EntityViewDisplay; use Drupal\Core\StringTranslation\StringTranslationTrait; /** - * Service for managing Turnstile fields on content types. + * Service for managing GNU Taler Turnstile fields on content types. */ class TurnstileFieldManager { @@ -37,7 +37,7 @@ class TurnstileFieldManager { } /** - * Add Turnstile fields to specified content types. + * Add GNU Taler Turnstile fields to specified content types. * * @param array $bundles * Array of content type machine names. @@ -61,15 +61,15 @@ class TurnstileFieldManager { */ protected function ensureFieldStorageExists() { // Create price category field storage if it doesn't exist. - $field_category_storage = FieldStorageConfig::loadByName('node', 'field_turnstile_price_category'); + $field_category_storage = FieldStorageConfig::loadByName('node', 'field_taler_turnstile_prcat'); if (!$field_category_storage) { $field_category_storage = FieldStorageConfig::create([ - 'field_name' => 'field_turnstile_price_category', + 'field_name' => 'field_taler_turnstile_prcat', 'entity_type' => 'node', 'type' => 'entity_reference', 'cardinality' => 1, 'settings' => [ - 'target_type' => 'turnstile_price_category', + 'target_type' => 'taler_turnstile_price_category', ], ]); $field_category_storage->save(); @@ -83,13 +83,13 @@ class TurnstileFieldManager { * The bundle machine name. */ protected function addPriceCategoryField($bundle) { - $field_category_storage = FieldStorageConfig::loadByName('node', 'field_turnstile_price_category'); + $field_category_storage = FieldStorageConfig::loadByName('node', 'field_taler_turnstile_prcat'); - $existing_field = FieldConfig::loadByName('node', $bundle, 'field_turnstile_price_category'); + $existing_field = FieldConfig::loadByName('node', $bundle, 'field_taler_turnstile_prcat'); if (!$existing_field) { // Create field configuration. $field_config = FieldConfig::create([ - 'field_name' => 'field_turnstile_price_category', + 'field_name' => 'field_taler_turnstile_prcat', 'entity_type' => 'node', 'field_storage' => $field_category_storage, 'bundle' => $bundle, @@ -97,7 +97,7 @@ class TurnstileFieldManager { 'description' => $this->t('Select a price category for this content.'), 'required' => FALSE, 'settings' => [ - 'handler' => 'default:turnstile_price_category', + 'handler' => 'default:taler_turnstile_price_category', 'handler_settings' => [ 'target_bundles' => NULL, 'sort' => [ @@ -113,7 +113,7 @@ class TurnstileFieldManager { // Add to form display. $form_display = EntityFormDisplay::load('node.' . $bundle . '.default'); if ($form_display) { - $form_display->setComponent('field_turnstile_price_category', [ + $form_display->setComponent('field_taler_turnstile_prcat', [ 'type' => 'options_select', 'weight' => 10, 'settings' => [], @@ -124,7 +124,7 @@ class TurnstileFieldManager { // Add to view display. $view_display = EntityViewDisplay::load('node.' . $bundle . '.default'); if ($view_display) { - $view_display->setComponent('field_turnstile_price_category', [ + $view_display->setComponent('field_taler_turnstile_prcat', [ 'type' => 'entity_reference_label', 'weight' => 10, 'label' => 'above', @@ -138,14 +138,14 @@ class TurnstileFieldManager { } /** - * Remove Turnstile fields from specified content types. + * Remove GNU Taler Turnstile fields from specified content types. * * @param array $bundles * Array of content type machine names. */ public function removeFieldsFromContentTypes(array $bundles) { foreach ($bundles as $bundle) { - $field_config = FieldConfig::loadByName('node', $bundle, 'field_turnstile_price_category'); + $field_config = FieldConfig::loadByName('node', $bundle, 'field_taler_turnstile_prcat'); if ($field_config) { $field_config->delete(); } @@ -153,14 +153,14 @@ class TurnstileFieldManager { // Remove from form display. $form_display = EntityFormDisplay::load('node.' . $bundle . '.default'); if ($form_display) { - $form_display->removeComponent('field_turnstile_price_category'); + $form_display->removeComponent('field_taler_turnstile_prcat'); $form_display->save(); } // Remove from view display. $view_display = EntityViewDisplay::load('node.' . $bundle . '.default'); if ($view_display) { - $view_display->removeComponent('field_turnstile_price_category'); + $view_display->removeComponent('field_taler_turnstile_prcat'); $view_display->save(); } } @@ -172,7 +172,7 @@ class TurnstileFieldManager { * Clean up field storage if no content types are using it. */ protected function cleanupFieldStorage() { - $field_category_storage = FieldStorageConfig::loadByName('node', 'field_turnstile_price_category'); + $field_category_storage = FieldStorageConfig::loadByName('node', 'field_taler_turnstile_prcat'); if ($field_category_storage) { $field_configs = $this->entityTypeManager ->getStorage('field_config') diff --git a/taler_turnstile.info.yml b/taler_turnstile.info.yml @@ -0,0 +1,11 @@ +name: GNU Taler Turnstile +type: module +description: 'Adds price field to nodes and requires payment for access.' +core_version_requirement: ^9 || ^10 +package: System +version: '0.9.0' +dependencies: + - drupal:node + - drupal:field + - drupal:user +configure: taler_turnstile.settings diff --git a/taler_turnstile.install b/taler_turnstile.install @@ -0,0 +1,41 @@ +<?php + +/** + * @file + * Install, update and uninstall functions for the Turnstile module. + */ + +use Drupal\field\Entity\FieldStorageConfig; +use Drupal\field\Entity\FieldConfig; +use Drupal\Core\Entity\Entity\EntityFormDisplay; +use Drupal\Core\Entity\Entity\EntityViewDisplay; +use Drupal\taler_turnstile\TurnstileFieldManager; + +/** + * Implements hook_install(). + */ +function taler_turnstile_install() { + $config = \Drupal::config('taler_turnstile.settings'); + $enabled_types = $config->get('enabled_content_types') ?: ['article']; + + /** @var TurnstileFieldManager $field_manager */ + $field_manager = \Drupal::service('taler_turnstile.field_manager'); + $field_manager->addFieldsToContentTypes($enabled_types); +} + +/** + * Implements hook_uninstall(). + */ +function taler_turnstile_uninstall() { + /** @var TurnstileFieldManager $field_manager */ + $field_manager = \Drupal::service('taler_turnstile.field_manager'); + + $config = \Drupal::config('taler_turnstile.settings'); + $enabled_types = $config->get('enabled_content_types') ?: []; + if (!empty($enabled_types)) { + $field_manager->removeFieldsFromContentTypes($enabled_types); + } + + // Clean up configuration. + \Drupal::configFactory()->getEditable('taler_turnstile.settings')->delete(); +} +\ No newline at end of file diff --git a/taler_turnstile.libraries.yml b/taler_turnstile.libraries.yml @@ -0,0 +1,14 @@ +payment_button: + version: 1.x + js: + js/payment-button.js: {} + dependencies: + - core/jquery + - core/drupal + - core/once + - taler_turnstile/qrcode + +qrcode: + version: 1.x + js: + js/qrcode.min.js: { minified: true } diff --git a/taler_turnstile.links.action.yml b/taler_turnstile.links.action.yml @@ -0,0 +1,7 @@ +# Action links for adding new price categories. + +entity.taler_turnstile_price_category.add_form: + route_name: entity.taler_turnstile_price_category.add_form + title: 'Add price category' + appears_on: + - entity.taler_turnstile_price_category.collection diff --git a/taler_turnstile.links.menu.yml b/taler_turnstile.links.menu.yml @@ -0,0 +1,20 @@ +taler_turnstile.settings: + title: 'GNU Taler Turnstile basics' + description: 'Configure GNU Taler payment backend and paid content types.' + parent: system.admin_config_system + route_name: taler_turnstile.settings + weight: 98 + +taler_turnstile.subscription_prices: + title: 'GNU Taler Turnstile subscription prices' + description: 'Configure prices for Turnstile subscriptions.' + parent: system.admin_config_system + route_name: taler_turnstile.subscription_prices + weight: 99 + +taler_turnstile.taler_turnstile_price_category.collection: + title: 'GNU Taler Turnstile price categories' + route_name: entity.taler_turnstile_price_category.collection + description: 'Manage price categories for the GNU Taler Turnstile.' + parent: system.admin_structure + weight: 10 diff --git a/taler_turnstile.module b/taler_turnstile.module @@ -0,0 +1,290 @@ +<?php + +/** + * @file + * Main module file for Turnstile. + */ + +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Entity\Display\EntityViewDisplayInterface; +use Drupal\node\NodeInterface; + + +/** + * Implements hook_form_FORM_ID_alter() for node forms. Adds a + * description for the Turnstile price category field. + */ +function taler_turnstile_form_node_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) { + $node = $form_state->getFormObject()->getEntity(); + $config = \Drupal::config('taler_turnstile.settings'); + $enabled_types = $config->get('enabled_content_types') ?: []; + + // Only show price field on enabled content types. + if (! in_array($node->bundle(), $enabled_types)) { + return; + } + if (! isset($form['field_taler_turnstile_prcat'])) { + return; + } + $form['field_taler_turnstile_prcat']['#group'] = 'meta'; + $form['field_taler_turnstile_prcat']['widget'][0]['value']['#description'] = t('Set a price category to enable paywall protection for this content.'); + + // Load all price categories for the description. + $price_categories = \Drupal::entityTypeManager() + ->getStorage('taler_turnstile_price_category') + ->loadMultiple(); + + $category_list = []; + foreach ($price_categories as $category) { + $category_list[] = $category->label() . ': ' . $category->getDescription(); + } + + $description = t('Select a price category to enable paywall protection for this content.'); + if (!empty($category_list)) { + $description .= '<br><br><strong>' . t('Available categories:') . '</strong><ul><li>' + . implode('</li><li>', $category_list) . '</li></ul>'; + } + + $form['field_taler_turnstile_prcat']['widget']['#description'] = $description; +} + + +/** + * Implements hook_entity_view_alter(). Transforms the body of an entity to + * show the Turnstile dialog instead of the full body if the user needs + * to pay to see the full article. + */ +function taler_turnstile_entity_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) { + // Only process nodes with turnstile enabled + if ($entity->getEntityTypeId() !== 'node') { + return; + } + /** @var \Drupal\node\NodeInterface $node */ + $node = $entity; + + if (!$node->hasField('field_taler_turnstile_prcat')) { + return; + } + + /** @var \Drupal\Core\Field\EntityReferenceFieldItemList $field */ + $field = $node->get('field_taler_turnstile_prcat'); + if ($field->isEmpty()) { + \Drupal::logger('taler_turnstile')->debug('No price category selected'); + return FALSE; + } + + /** @var TurnstilePriceCategory $price_category */ + $price_category = $field->entity; + if (! $price_category) { + \Drupal::logger('taler_turnstile')->debug('Node has no price category, skipping payment.'); + return; + } + + $view_mode = $display->getMode(); + if ($view_mode !== 'full') { + \Drupal::logger('taler_turnstile')->debug('Turnstile only active for "Full" view mode.'); + return; + } + + $subscriptions = $price_category->getFullSubscriptions(); + foreach ($subscriptions as $subscription_id) { + if (_taler_turnstile_is_subscriber ($subscription_id)) { + \Drupal::logger('taler_turnstile')->debug('Subscriber detected, granting access.'); + return; + } + } + + // Disable page cache, this page is personalized! + \Drupal::service('page_cache_kill_switch')->trigger(); + + $node_id = $node->id(); + if (_taler_turnstile_has_session_access($node_id)) { + \Drupal::logger('taler_turnstile')->debug('Session has access to this node.'); + return; + } + + /** @var \Drupal\taler_turnstile\TalerMerchantApiService $api_service */ + $api_service = \Drupal::service('taler_turnstile.api_service'); + + $order_info = _taler_turnstile_get_node_order_info ($node_id); + if ($order_info) { + \Drupal::logger('taler_turnstile')->debug('Found existing order @ORDER_ID for this session.', [ '@ORDER_ID' => $order_info['order_id'] ]); + // We have an existing order, check if it was paid + $order_id = $order_info['order_id']; + $order_status = $api_service->checkOrderStatus($order_info['order_id']); + if ($order_status && $order_status['paid']) { + \Drupal::logger('taler_turnstile')->debug('Order was paid, granting session access.'); + _taler_turnstile_grant_session_access($node_id); + if ($order_status['subscription_slug'] ?? FALSE) { + \Drupal::logger('taler_turnstile')->debug('Subscription was purchased, granting subscription access.'); + $subscription_slug = $order_status['subscription_slug']; + $expiration = $order_status['subscription_expiration']; + _taler_turnstile_grant_subscriber_access ($subscription_slug, $expiration); + } + return; + } + if ($order_status && + ($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) + { + $order_info = NULL; + } + else + { + \Drupal::logger('taler_turnstile')->debug('Order expires in @future seconds, not creating new one.', ['@future' => ($order_status['order_expiration'] ?? 0) - time ()] ); + } + } + if (!$order_info) { + // Need to try to create a new order + $order_info = $api_service->createOrder($node); + } + if (!$order_info) { + \Drupal::logger('taler_turnstile')->warning('Failed to setup order with Taler merchant backend!'); + $config = \Drupal::config('taler_turnstile.settings'); + $grant_access_on_error = $config->get('grant_access_on_error') ?? TRUE; + if ($grant_access_on_error) { + \Drupal::logger('taler_turnstile')->debug('Could not setup order, disabling Turnstile.'); + return; + } + $pay_button = [ + '#markup' => '<div class="taler-turnstile-error">' . t('Payment system temporarily unavailable. Please try again later.') . '</div>', + ]; + } + else + { + _taler_turnstile_store_order_node_mapping($node_id, $order_info); + $pay_button = [ + '#theme' => 'taler_turnstile_payment_button', + '#order_id' => $order_info['order_id'], + '#session_id' => $order_info['session_id'], + '#payment_url' => $order_info['payment_url'], + '#node_title' => $node->getTitle(), + '#attached' => [ + 'library' => ['taler_turnstile/payment_button'], + ], + ]; + } + // User needs to pay - replace full content with teaser + payment button + // Generate teaser view mode + $view_builder = \Drupal::entityTypeManager()->getViewBuilder('node'); + $teaser_build = $view_builder->view($entity, 'teaser'); + + // Replace the build array with teaser content + // Keep important metadata from original build (?) + $build = [ + '#cache' => ['contexts' => ['url']], + '#weight' => $build['#weight'] ?? 0, + ]; + + // Add teaser content + $build['teaser'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['taler-turnstile-teaser-wrapper']], + 'content' => $teaser_build, + '#weight' => 0, + ]; + + // Add payment button + $build['payment_button'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['taler-turnstile-payment-wrapper']], + 'button' => $pay_button, + '#weight' => 10, + ]; +} + + +/** + * Helper function to grant subscription access for this + * visitor to the given node ID until the given expiration time. + */ +function _taler_turnstile_grant_subscriber_access($subscription_slug, $expiration) { + $session = \Drupal::request()->getSession(); + $access_data = $session->get('taler_turnstile_subscriptions', []); + $access_data[$subscription_slug] = $expiration; + $session->set('taler_turnstile_subscriptions', $access_data); +} + + +/** + * Helper function to check if this session is currently + * subscribed on the given type of subscription. + */ +function _taler_turnstile_is_subscriber($subscription_slug) { + $session = \Drupal::request()->getSession(); + $access_data = $session->get('taler_turnstile_subscriptions', []); + return ($access_data[$subscription_slug] ?? 0) >= time(); +} + + +/** + * Helper function to grant session access for this + * visitor to the given node ID. + */ +function _taler_turnstile_grant_session_access($node_id) { + $session = \Drupal::request()->getSession(); + $access_data = $session->get('taler_turnstile_access', []); + $access_data[$node_id] = TRUE; + $session->set('taler_turnstile_access', $access_data); +} + + +/** + * Helper function to check session access. Checks if this + * visitor has been granted access to the given $node_id. + */ +function _taler_turnstile_has_session_access($node_id) { + $session = \Drupal::request()->getSession(); + $access_data = $session->get('taler_turnstile_access', []); + return $access_data[$node_id] ?? FALSE; +} + + +/** + * Store the mapping between order_id and node_id. + * Uses session to track which orders belong to which nodes. + */ +function _taler_turnstile_store_order_node_mapping($node_id, $order_info) { + $session = \Drupal::request()->getSession(); + $node_orders = $session->get('taler_turnstile_node_orders', []); + $node_orders[$node_id] = $order_info; + $session->set('taler_turnstile_node_orders', $node_orders); +} + + +/** + * Get the order_info associated with a node_id. + */ +function _taler_turnstile_get_node_order_info($node_id) { + $session = \Drupal::request()->getSession(); + $node_orders = $session->get('taler_turnstile_node_orders', []); + return $node_orders[$node_id] ?? NULL; +} + + +/** + * Implements hook_theme(). + */ +function taler_turnstile_theme() { + return [ + 'taler_turnstile_payment_button' => [ + 'variables' => [ + 'order_id' => NULL, + 'session_id' => NULL, + 'payment_url' => NULL, + 'node_title' => NULL, + ], + 'template' => 'taler-turnstile-payment-button', + ], + 'taler_turnstile_settings' => [ + 'variables' => [ + 'config' => NULL, + ], + ], + ]; +} diff --git a/taler_turnstile.permissions.yml b/taler_turnstile.permissions.yml @@ -0,0 +1,9 @@ +administer taler_turnstile: + title: 'Administer GNU Taler Turnstile' + description: 'Configure Turnstile settings and manage payment options.' + restrict access: true + +administer price categories: + title: 'Administer price categories' + description: 'Create, edit, and delete price categories.' + restrict access: true diff --git a/taler_turnstile.routing.yml b/taler_turnstile.routing.yml @@ -0,0 +1,53 @@ +taler_turnstile.settings: + path: '/admin/config/system/taler-turnstile' + defaults: + _form: '\Drupal\taler_turnstile\Form\TurnstileSettingsForm' + _title: 'GNU Taler Turnstile settings' + requirements: + _permission: 'administer GNU Taler Turnstile' + options: + _admin_route: TRUE + +# Route for editing subscription prices +taler_turnstile.subscription_prices: + path: '/admin/config/system/taler_turnstile/subscription-prices' + defaults: + _form: '\Drupal\taler_turnstile\Form\SubscriptionPricesForm' + _title: 'Subscription prices' + requirements: + _permission: 'administer GNU Taler Turnstile' + options: + _admin_route: TRUE + +# Routes for price categories. +entity.taler_turnstile_price_category.collection: + path: '/admin/structure/taler-turnstile-price-categories' + defaults: + _entity_list: 'taler_turnstile_price_category' + _title: 'Price categories' + requirements: + _permission: 'administer price categories' + +entity.taler_turnstile_price_category.add_form: + path: '/admin/structure/taler-turnstile-price-categories/add' + defaults: + _entity_form: 'taler_turnstile_price_category.add' + _title: 'Add price category' + requirements: + _permission: 'administer price categories' + +entity.taler_turnstile_price_category.edit_form: + path: '/admin/structure/taler-turnstile-price-categories/{taler_turnstile_price_category}/edit' + defaults: + _entity_form: 'taler_turnstile_price_category.edit' + _title: 'Edit price category' + requirements: + _permission: 'administer price categories' + +entity.taler_turnstile_price_category.delete_form: + path: '/admin/structure/taler-turnstile-price-categories/{taler_turnstile_price_category}/delete' + defaults: + _entity_form: 'taler_turnstile_price_category.delete' + _title: 'Delete price category' + requirements: + _permission: 'administer price categories' diff --git a/taler_turnstile.services.yml b/taler_turnstile.services.yml @@ -0,0 +1,12 @@ +services: + taler_turnstile.api_service: + class: Drupal\taler_turnstile\TalerMerchantApiService + arguments: ['@http_client_factory', '@logger.channel.taler_turnstile'] + + taler_turnstile.field_manager: + class: Drupal\taler_turnstile\TurnstileFieldManager + arguments: ['@entity_type.manager'] + + logger.channel.taler_turnstile: + parent: logger.channel_base + arguments: ['taler-turnstile'] diff --git a/templates/taler-turnstile-payment-button.html.twig b/templates/taler-turnstile-payment-button.html.twig @@ -0,0 +1,157 @@ +<div class="taler-turnstile-payment-container"> + <div class="taler-turnstile-payment-info"> + <h3>{{ 'Payment required'|t }}</h3> + <p>{{ 'Please pay to access'|t }} <strong>{{ node_title }}</strong>.</p> + </div> + + <div class="taler-turnstile-payment-actions"> + <div class="taler-turnstile-payment-qr"> + <div class="taler-turnstile-qr-code-container" + data-payment-url="{{ payment_url }}" + data-order-id="{{ order_id }}" + data-session-id="{{ session_id }}"></div> + <p class="taler-turnstile-qr-help">{{ 'Scan with your GNU Taler wallet'|t }}</p> + </div> + + <div class="taler-turnstile-payment-or"> + <span>{{ 'or'|t }}</span> + </div> + + <a href="{{ payment_url }}" + class="button button--primary taler-turnstile-pay-button" + data-order-id="{{ order_id }}" + data-session-id="{{ session_id }}"> + {{ 'Open GNU Taler payment Web page'|t }} + </a> + </div> + + <div class="taler-turnstile-payment-status"> + <p class="taler-turnstile-status-message">{{ 'Waiting for payment...'|t }}</p> + </div> + +</div> + +<style> +.taler-turnstile-payment-container { + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 2rem; + margin: 2rem 0; + background: #f9f9f9; +} + +.taler-turnstile-payment-info h3 { + margin-top: 0; + color: #333; +} + +.taler-turnstile-price { + font-size: 1.2rem; + font-weight: bold; + color: #0066cc; + margin: 1rem 0; +} + +.taler-turnstile-payment-actions { + margin-top: 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.taler-turnstile-payment-qr { + text-align: center; + padding: 1rem; + background: white; + border-radius: 4px; + border: 1px solid #ddd; +} + +.taler-turnstile-qr-code { + display: block; + margin: 0 auto; + max-width: 200px; + height: auto; +} + +.taler-turnstile-qr-help { + margin: 0.5rem 0 0 0; + font-size: 0.9rem; + color: #666; +} + +.taler-turnstile-payment-or { + margin: 0.5rem 0; + color: #666; + font-weight: bold; +} + +.taler-turnstile-pay-button { + display: inline-block; + padding: 0.75rem 2rem; + background: #0066cc; + color: white; + text-decoration: none; + border-radius: 4px; + font-weight: bold; + transition: background 0.3s; +} + +.taler-turnstile-pay-button:hover { + background: #0052a3; + color: white; +} + +.taler-turnstile-payment-status { + margin-top: 1.5rem; + padding: 1rem; + background: #e3f2fd; + border: 1px solid #90caf9; + border-radius: 4px; + text-align: center; +} + +.taler-turnstile-status-message { + margin: 0; + color: #1565c0; + font-style: italic; +} + +.taler-turnstile-access-message { + padding: 1rem; + margin: 1rem 0; + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; +} + +.taler-turnstile-teaser-wrapper { + position: relative; + max-height: 400px; + overflow: hidden; +} + +.taler-turnstile-teaser-wrapper::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 100px; + background: linear-gradient(to bottom, transparent, white); +} + +/* Responsive design */ +@media (min-width: 768px) { + .taler-turnstile-payment-actions { + flex-direction: row; + justify-content: center; + align-items: center; + } + + .taler-turnstile-payment-or { + margin: 0 1rem; + } +} +</style> diff --git a/templates/turnstile-payment-button.html.twig b/templates/turnstile-payment-button.html.twig @@ -1,156 +0,0 @@ -<div class="turnstile-payment-container"> - <div class="turnstile-payment-info"> - <h3>{{ 'Payment required'|t }}</h3> - <p>{{ 'Please pay to access'|t }} <strong>{{ node_title }}</strong>.</p> - </div> - - <div class="turnstile-payment-actions"> - <div class="turnstile-payment-qr"> - <div class="turnstile-qr-code-container" - data-order-id="{{ order_id }}" - data-session-id="{{ session_id }}"></div> - <p class="turnstile-qr-help">{{ 'Scan with your GNU Taler wallet'|t }}</p> - </div> - - <div class="turnstile-payment-or"> - <span>{{ 'or'|t }}</span> - </div> - - <a href="{{ payment_url }}" - class="button button--primary turnstile-pay-button" - data-order-id="{{ order_id }}" - data-session-id="{{ session_id }}"> - {{ 'Open GNU Taler payment Web page'|t }} - </a> - </div> - - <div class="turnstile-payment-status"> - <p class="turnstile-status-message">{{ 'Waiting for payment...'|t }}</p> - </div> - -</div> - -<style> -.turnstile-payment-container { - border: 2px solid #e0e0e0; - border-radius: 8px; - padding: 2rem; - margin: 2rem 0; - background: #f9f9f9; -} - -.turnstile-payment-info h3 { - margin-top: 0; - color: #333; -} - -.turnstile-price { - font-size: 1.2rem; - font-weight: bold; - color: #0066cc; - margin: 1rem 0; -} - -.turnstile-payment-actions { - margin-top: 1.5rem; - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; -} - -.turnstile-payment-qr { - text-align: center; - padding: 1rem; - background: white; - border-radius: 4px; - border: 1px solid #ddd; -} - -.turnstile-qr-code { - display: block; - margin: 0 auto; - max-width: 200px; - height: auto; -} - -.turnstile-qr-help { - margin: 0.5rem 0 0 0; - font-size: 0.9rem; - color: #666; -} - -.turnstile-payment-or { - margin: 0.5rem 0; - color: #666; - font-weight: bold; -} - -.turnstile-pay-button { - display: inline-block; - padding: 0.75rem 2rem; - background: #0066cc; - color: white; - text-decoration: none; - border-radius: 4px; - font-weight: bold; - transition: background 0.3s; -} - -.turnstile-pay-button:hover { - background: #0052a3; - color: white; -} - -.turnstile-payment-status { - margin-top: 1.5rem; - padding: 1rem; - background: #e3f2fd; - border: 1px solid #90caf9; - border-radius: 4px; - text-align: center; -} - -.turnstile-status-message { - margin: 0; - color: #1565c0; - font-style: italic; -} - -.turnstile-access-message { - padding: 1rem; - margin: 1rem 0; - background: #fff3cd; - border: 1px solid #ffc107; - border-radius: 4px; -} - -.turnstile-teaser-wrapper { - position: relative; - max-height: 400px; - overflow: hidden; -} - -.turnstile-teaser-wrapper::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 100px; - background: linear-gradient(to bottom, transparent, white); -} - -/* Responsive design */ -@media (min-width: 768px) { - .turnstile-payment-actions { - flex-direction: row; - justify-content: center; - align-items: center; - } - - .turnstile-payment-or { - margin: 0 1rem; - } -} -</style> diff --git a/turnstile.info.yml b/turnstile.info.yml @@ -1,11 +0,0 @@ -name: Turnstile -type: module -description: 'Adds price field to nodes and requires payment for access.' -core_version_requirement: ^9 || ^10 -package: System -version: '0.9.0' -dependencies: - - drupal:node - - drupal:field - - drupal:user -configure: turnstile.settings diff --git a/turnstile.install b/turnstile.install @@ -1,40 +0,0 @@ -<?php - -/** - * @file - * Install, update and uninstall functions for the Turnstile module. - */ - -use Drupal\field\Entity\FieldStorageConfig; -use Drupal\field\Entity\FieldConfig; -use Drupal\Core\Entity\Entity\EntityFormDisplay; -use Drupal\Core\Entity\Entity\EntityViewDisplay; - -/** - * Implements hook_install(). - */ -function turnstile_install() { - $config = \Drupal::config('turnstile.settings'); - $enabled_types = $config->get('enabled_content_types') ?: ['article']; - - /** @var TurnstileFieldManager $field_manager */ - $field_manager = \Drupal::service('turnstile.field_manager'); - $field_manager->addFieldsToContentTypes($enabled_types); -} - -/** - * Implements hook_uninstall(). - */ -function turnstile_uninstall() { - /** @var TurnstileFieldManager $field_manager */ - $field_manager = \Drupal::service('turnstile.field_manager'); - - $config = \Drupal::config('turnstile.settings'); - $enabled_types = $config->get('enabled_content_types') ?: []; - if (!empty($enabled_types)) { - $field_manager->removeFieldsFromContentTypes($enabled_types); - } - - // Clean up configuration. - \Drupal::configFactory()->getEditable('turnstile.settings')->delete(); -} -\ No newline at end of file diff --git a/turnstile.libraries.yml b/turnstile.libraries.yml @@ -1,14 +0,0 @@ -payment_button: - version: 1.x - js: - js/payment-button.js: {} - dependencies: - - core/jquery - - core/drupal - - core/once - - turnstile/qrcode - -qrcode: - version: 1.x - js: - js/qrcode.min.js: { minified: true } diff --git a/turnstile.links.action.yml b/turnstile.links.action.yml @@ -1,7 +0,0 @@ -# Action links for adding new price categories. - -entity.turnstile_price_category.add_form: - route_name: entity.turnstile_price_category.add_form - title: 'Add price category' - appears_on: - - entity.turnstile_price_category.collection diff --git a/turnstile.links.menu.yml b/turnstile.links.menu.yml @@ -1,20 +0,0 @@ -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: 'Turnstile price categories' - route_name: entity.turnstile_price_category.collection - description: 'Manage price categories for Turnstile.' - parent: system.admin_structure - weight: 10 diff --git a/turnstile.module b/turnstile.module @@ -1,290 +0,0 @@ -<?php - -/** - * @file - * Main module file for Turnstile. - */ - -use Drupal\Core\Entity\EntityInterface; -use Drupal\Core\Entity\EntityTypeInterface; -use Drupal\Core\Entity\Display\EntityViewDisplayInterface; -use Drupal\node\NodeInterface; - - -/** - * Implements hook_form_FORM_ID_alter() for node forms. Adds a - * description for the Turnstile price category field. - */ -function turnstile_form_node_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) { - $node = $form_state->getFormObject()->getEntity(); - $config = \Drupal::config('turnstile.settings'); - $enabled_types = $config->get('enabled_content_types') ?: []; - - // Only show price field on enabled content types. - if (! in_array($node->bundle(), $enabled_types)) { - return; - } - if (! isset($form['field_turnstile_price_category'])) { - return; - } - $form['field_turnstile_price_category']['#group'] = 'meta'; - $form['field_turnstile_price_category']['widget'][0]['value']['#description'] = t('Set a price category to enable paywall protection for this content.'); - - // Load all price categories for the description. - $price_categories = \Drupal::entityTypeManager() - ->getStorage('turnstile_price_category') - ->loadMultiple(); - - $category_list = []; - foreach ($price_categories as $category) { - $category_list[] = $category->label() . ': ' . $category->getDescription(); - } - - $description = t('Select a price category to enable paywall protection for this content.'); - if (!empty($category_list)) { - $description .= '<br><br><strong>' . t('Available categories:') . '</strong><ul><li>' - . implode('</li><li>', $category_list) . '</li></ul>'; - } - - $form['field_turnstile_price_category']['widget']['#description'] = $description; -} - - -/** - * Implements hook_entity_view_alter(). Transforms the body of an entity to - * show the Turnstile dialog instead of the full body if the user needs - * to pay to see the full article. - */ -function turnstile_entity_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) { - // Only process nodes with turnstile enabled - if ($entity->getEntityTypeId() !== 'node') { - return; - } - /** @var \Drupal\node\NodeInterface $node */ - $node = $entity; - - if (!$node->hasField('field_turnstile_price_category')) { - return; - } - - /** @var \Drupal\Core\Field\EntityReferenceFieldItemList $field */ - $field = $node->get('field_turnstile_price_category'); - if ($field->isEmpty()) { - \Drupal::logger('turnstile')->debug('No price category selected'); - return FALSE; - } - - /** @var TurnstilePriceCategory $price_category */ - $price_category = $field->entity; - if (! $price_category) { - \Drupal::logger('turnstile')->debug('Node has no price category, skipping payment.'); - return; - } - - $view_mode = $display->getMode(); - if ($view_mode !== 'full') { - \Drupal::logger('turnstile')->debug('Turnstile only active for "Full" view mode.'); - 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(); - - $node_id = $node->id(); - if (_turnstile_has_session_access($node_id)) { - \Drupal::logger('turnstile')->debug('Session has access to this node.'); - return; - } - - /** @var \Drupal\turnstile\TalerMerchantApiService $api_service */ - $api_service = \Drupal::service('turnstile.api_service'); - - $order_info = _turnstile_get_node_order_info ($node_id); - if ($order_info) { - \Drupal::logger('turnstile')->debug('Found existing order @ORDER_ID for this session.', [ '@ORDER_ID' => $order_info['order_id'] ]); - // We have an existing order, check if it was paid - $order_id = $order_info['order_id']; - $order_status = $api_service->checkOrderStatus($order_info['order_id']); - 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() + 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) - { - $order_info = NULL; - } - else - { - \Drupal::logger('turnstile')->debug('Order expires in @future seconds, not creating new one.', ['@future' => ($order_status['order_expiration'] ?? 0) - time ()] ); - } - } - if (!$order_info) { - // Need to try to create a new order - $order_info = $api_service->createOrder($node); - } - if (!$order_info) { - \Drupal::logger('turnstile')->warning('Failed to setup order with Taler merchant backend!'); - $config = \Drupal::config('turnstile.settings'); - $grant_access_on_error = $config->get('grant_access_on_error') ?? TRUE; - if ($grant_access_on_error) { - \Drupal::logger('turnstile')->debug('Could not setup order, disabling Turnstile.'); - return; - } - $pay_button = [ - '#markup' => '<div class="turnstile-error">' . t('Payment system temporarily unavailable. Please try again later.') . '</div>', - ]; - } - else - { - _turnstile_store_order_node_mapping($node_id, $order_info); - $pay_button = [ - '#theme' => 'turnstile_payment_button', - '#order_id' => $order_info['order_id'], - '#session_id' => $order_info['session_id'], - '#payment_url' => $order_info['payment_url'], - '#node_title' => $node->getTitle(), - '#attached' => [ - 'library' => ['turnstile/payment_button'], - ], - ]; - } - // User needs to pay - replace full content with teaser + payment button - // Generate teaser view mode - $view_builder = \Drupal::entityTypeManager()->getViewBuilder('node'); - $teaser_build = $view_builder->view($entity, 'teaser'); - - // Replace the build array with teaser content - // Keep important metadata from original build (?) - $build = [ - '#cache' => ['contexts' => ['url']], - '#weight' => $build['#weight'] ?? 0, - ]; - - // Add teaser content - $build['teaser'] = [ - '#type' => 'container', - '#attributes' => ['class' => ['turnstile-teaser-wrapper']], - 'content' => $teaser_build, - '#weight' => 0, - ]; - - // Add payment button - $build['payment_button'] = [ - '#type' => 'container', - '#attributes' => ['class' => ['turnstile-payment-wrapper']], - 'button' => $pay_button, - '#weight' => 10, - ]; -} - - -/** - * 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. - */ -function _turnstile_grant_session_access($node_id) { - $session = \Drupal::request()->getSession(); - $access_data = $session->get('turnstile_access', []); - $access_data[$node_id] = TRUE; - $session->set('turnstile_access', $access_data); -} - - -/** - * Helper function to check session access. Checks if this - * visitor has been granted access to the given $node_id. - */ -function _turnstile_has_session_access($node_id) { - $session = \Drupal::request()->getSession(); - $access_data = $session->get('turnstile_access', []); - return $access_data[$node_id] ?? FALSE; -} - - -/** - * Store the mapping between order_id and node_id. - * Uses session to track which orders belong to which nodes. - */ -function _turnstile_store_order_node_mapping($node_id, $order_info) { - $session = \Drupal::request()->getSession(); - $node_orders = $session->get('turnstile_node_orders', []); - $node_orders[$node_id] = $order_info; - $session->set('turnstile_node_orders', $node_orders); -} - - -/** - * Get the order_info associated with a node_id. - */ -function _turnstile_get_node_order_info($node_id) { - $session = \Drupal::request()->getSession(); - $node_orders = $session->get('turnstile_node_orders', []); - return $node_orders[$node_id] ?? NULL; -} - - -/** - * Implements hook_theme(). - */ -function turnstile_theme() { - return [ - 'turnstile_payment_button' => [ - 'variables' => [ - 'order_id' => NULL, - 'session_id' => NULL, - 'payment_url' => NULL, - 'node_title' => NULL, - ], - 'template' => 'turnstile-payment-button', - ], - 'turnstile_settings' => [ - 'variables' => [ - 'config' => NULL, - ], - ], - ]; -} diff --git a/turnstile.permissions.yml b/turnstile.permissions.yml @@ -1,9 +0,0 @@ -administer turnstile: - title: 'Administer Turnstile' - description: 'Configure Turnstile settings and manage payment options.' - restrict access: true - -administer price categories: - title: 'Administer price categories' - description: 'Create, edit, and delete price categories.' - restrict access: true diff --git a/turnstile.routing.yml b/turnstile.routing.yml @@ -1,53 +0,0 @@ -turnstile.settings: - path: '/admin/config/system/Turnstile' - defaults: - _form: '\Drupal\turnstile\Form\TurnstileSettingsForm' - _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: - _admin_route: TRUE - -# Routes for price categories. -entity.turnstile_price_category.collection: - path: '/admin/structure/price-categories' - defaults: - _entity_list: 'turnstile_price_category' - _title: 'Price categories' - requirements: - _permission: 'administer price categories' - -entity.turnstile_price_category.add_form: - path: '/admin/structure/price-categories/add' - defaults: - _entity_form: 'turnstile_price_category.add' - _title: 'Add price category' - requirements: - _permission: 'administer price categories' - -entity.turnstile_price_category.edit_form: - path: '/admin/structure/price-categories/{turnstile_price_category}/edit' - defaults: - _entity_form: 'turnstile_price_category.edit' - _title: 'Edit price category' - requirements: - _permission: 'administer price categories' - -entity.turnstile_price_category.delete_form: - path: '/admin/structure/price-categories/{turnstile_price_category}/delete' - defaults: - _entity_form: 'turnstile_price_category.delete' - _title: 'Delete price category' - requirements: - _permission: 'administer price categories' diff --git a/turnstile.services.yml b/turnstile.services.yml @@ -1,12 +0,0 @@ -services: - turnstile.api_service: - class: Drupal\turnstile\TalerMerchantApiService - arguments: ['@http_client_factory', '@logger.channel.turnstile'] - - turnstile.field_manager: - class: Drupal\turnstile\TurnstileFieldManager - arguments: ['@entity_type.manager'] - - logger.channel.turnstile: - parent: logger.channel_base - arguments: ['turnstile']