turnstile

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

commit 9d6812f29325b17a6db45a51f7ad473d03f1ee07
parent 6279849d9330f686d9d1287e2255c6e190eb6490
Author: Christian Grothoff <christian@grothoff.org>
Date:   Sat, 11 Oct 2025 18:56:06 +0200

DCE

Diffstat:
MREADME.md | 8+++++++-
Msrc/Entity/TurnstilePriceCategory.php | 1+
Msrc/Form/TurnstileSettingsForm.php | 4++--
Dsrc/Plugin/Validation/Constraint/TalerPriceListFormatConstraint.php | 27---------------------------
Dsrc/Plugin/Validation/Constraint/TalerPriceListFormatConstraintValidator.php | 145-------------------------------------------------------------------------------
Msrc/TalerMerchantApiService.php | 21+++++++++++++--------
Msrc/TurnstileFieldManager.php | 96-------------------------------------------------------------------------------
Mturnstile.module | 134+++----------------------------------------------------------------------------
8 files changed, 28 insertions(+), 408 deletions(-)

diff --git a/README.md b/README.md @@ -46,8 +46,14 @@ Navigate to `/admin/config/content/Turnstile` to configure: ## TODO +- paywall rendering broke again, debug! - actually *use* price categories when determining article price! - => keep or remove price field? + => make v1 multi-currency orders work +- actually get subscriptions to work: v1 contracts, etc. + note that the logic is now more funky, customers could have a 50%-off + subscription. So need to check if we have a subscription cookie + set for a subscription that for the current category would give us + a price of zero! - Make truncation logic work with tiles / cards diff --git a/src/Entity/TurnstilePriceCategory.php b/src/Entity/TurnstilePriceCategory.php @@ -96,6 +96,7 @@ class TurnstilePriceCategory extends ConfigEntityBase { /** * Gets the price for a specific subscription and currency. + * FIXME: just an example for now, to be removed! * * @param string $subscription_id * The subscription ID. diff --git a/src/Form/TurnstileSettingsForm.php b/src/Form/TurnstileSettingsForm.php @@ -320,7 +320,7 @@ class TurnstileSettingsForm extends ConfigFormBase { } if (!empty($added_labels)) { $this->messenger()->addStatus( - $this->t('Price and price category fields added to: @types', [ + $this->t('Price category fields added to: @types', [ '@types' => implode(', ', $added_labels), ]) ); @@ -336,7 +336,7 @@ class TurnstileSettingsForm extends ConfigFormBase { } if (!empty($removed_labels)) { $this->messenger()->addStatus( - $this->t('Price and price category fields removed from: @types', [ + $this->t('Price category fields removed from: @types', [ '@types' => implode(', ', $removed_labels), ]) ); diff --git a/src/Plugin/Validation/Constraint/TalerPriceListFormatConstraint.php b/src/Plugin/Validation/Constraint/TalerPriceListFormatConstraint.php @@ -1,26 +0,0 @@ -<?php - -namespace Drupal\turnstile\Plugin\Validation\Constraint; - -use Symfony\Component\Validator\Constraint; - -/** - * Checks that the price format is valid. - * - * @Constraint( - * id = "TalerPriceListFormat", - * label = @Translation("Taler Price List Format", context = "Validation"), - * type = "string", - * ) - */ -class TalerPriceListFormatConstraint extends Constraint { - - public $invalidFormat = 'The price list "@value" is not valid. Please enter a valid price list (e.g., "EUR:1" or "USD:3,CHF:2" to support payment in multiple currencies).'; - - public $invalidCurrency = 'Price uses currency "@currency" that is not supported by the Taler merchant backend. The supported currencies are: @supported'; - - public $backendTrouble = 'Failed to fetch configuration data from Taler merchant backend.'; - - public $internalError = 'Failed to validate price list: @error'; - -} -\ No newline at end of file diff --git a/src/Plugin/Validation/Constraint/TalerPriceListFormatConstraintValidator.php b/src/Plugin/Validation/Constraint/TalerPriceListFormatConstraintValidator.php @@ -1,144 +0,0 @@ -<?php - -namespace Drupal\turnstile\Plugin\Validation\Constraint; - -use Symfony\Component\Validator\Constraint; -use Symfony\Component\Validator\ConstraintValidator; - -/** - * Validates the TalerPriceListFormat constraint. - */ -class TalerPriceListFormatConstraintValidator extends ConstraintValidator { - - /** - * {@inheritdoc} - */ - public function validate($item, Constraint $constraint) { - \Drupal::logger('turnstile')->debug('Validating price list'); - - if (!isset($item)) { - \Drupal::logger('turnstile')->debug('No item, skipping validation'); - return; - } - - $pricelist = $item->value; - - // Empty is OK, simply gratis. - if (empty($pricelist)) { - \Drupal::logger('turnstile')->debug('No pricelist, skipping validation'); - return; - } - - - $pattern = '/^(([A-Za-z]{1,11}:\d*(\.\d{1,8})?)(\s*,\s*[A-Za-z]{1,11}:\d*(\.\d{1,8})?)*)?$/'; - - if (preg_match($pattern, $pricelist) !== 1) { - \Drupal::logger('turnstile')->debug('Pricelist has invalid format, rejecting it'); - $this->context->addViolation($constraint->invalidFormat, [ - '@value' => $pricelist, - ]); - return; - } - - $config = \Drupal::config('turnstile.settings'); - $payment_backend_url = $config->get('payment_backend_url'); - $grant_access_on_error = $config->get('grant_access_on_error'); - - if (empty($payment_backend_url)) { - // If backend is not configured, allow the pricelist if - // grant_access_on_error is set. - \Drupal::logger('turnstile')->error('Taler merchant backend not configured; cannot validate pricelist'); - if ($grant_access_on_error) { - return; - } - $this->context->addViolation($constraint->backendTrouble); - return; - } - - try { - // Fetch backend configuration. - $client = \Drupal::httpClient(); - - $config_url = $payment_backend_url . 'config'; - $response = $client->get($config_url, [ - 'allow_redirects' => TRUE, - 'http_errors' => FALSE, - 'timeout' => 5, - ]); - - if ($response->getStatusCode() !== 200) { - // If backend is unavailable, allow the pricelist if - // grant_access_on_error is set. - \Drupal::logger('turnstile')->error('Taler merchant backend unavailable, cannot validate pricelist'); - $this->backendTrouble = TRUE; - if ($grant_access_on_error) { - return; - } - $this->context->addViolation($constraint->backendTrouble); - return; - } - - $backend_config = json_decode($response->getBody(), TRUE); - if (!$backend_config || !is_array($backend_config)) { - // Invalid response, fallback to grant_access_on_error setting. - \Drupal::logger('turnstile')->error('Taler merchant backend returned invalid /config response; cannot validate pricelist'); - $this->backendTrouble = TRUE; - if ($grant_access_on_error) { - return; - } - $this->context->addViolation($constraint->backendTrouble); - return; - } - - if (! isset($backend_config['currencies'])) - { - \Drupal::logger('turnstile')->error('Backend returned malformed response for /config'); - $this->context->addViolation($constraint->backendTrouble); - return; - } - - // Parse and validate each amount in the comma-separated list. - $amounts = preg_split('/\s*,\s*/', trim($pricelist)); - $currencies = $backend_config['currencies']; - - foreach ($amounts as $amount) { - // Extract currency from each amount (format: CURRENCY:NUMBER[.FRACTION]). - \Drupal::logger('turnstile')->debug('Checking amount @amount', [ - '@amount' => $amount, - ]); - if (preg_match('/^([A-Za-z]{1,11}):/', $amount, $matches)) { - $currency = $matches[1]; - \Drupal::logger('turnstile')->debug('Checking currency @currency', [ - '@currency' => $currency, - ]); - - if (! isset($currencies[strtoupper($currency)])) { - \Drupal::logger('turnstile')->debug('Unsupported currency in pricelist, rejecting it'); - $this->context->addViolation($constraint->invalidCurrency, [ - '@currency' => $currency, - '@supported' => implode(', ', array_keys($currencies)), - ]); - return; - } - } - } - \Drupal::logger('turnstile')->debug('Pricelist is OK'); - return; - - } catch (\Exception $e) { - - // On exception, fall back to grant_access_on_error setting. - \Drupal::logger('turnstile')->error('Failed to validate pricelist with backend: @error', [ - '@error' => $e->getMessage(), - ]); - if ($grant_access_on_error) { - return; - } - $this->context->addViolation($constraint->invalidCurrency, [ - '@error' => $e->getMessage(), - ]); - return; - } - } - -} -\ No newline at end of file diff --git a/src/TalerMerchantApiService.php b/src/TalerMerchantApiService.php @@ -10,7 +10,9 @@ namespace Drupal\turnstile; use Drupal\Core\Http\ClientFactory; +use Drupal\node\NodeInterface; use Psr\Log\LoggerInterface; +use Drupal\turnstile\Entity\TurnstilePriceCategory; @@ -254,21 +256,21 @@ class TalerMerchantApiService { case 404: // Order unknown or instance unknown /** @var TalerErrorCode $ec */ - $ec = $result['code'] ?? TALER_EC_NONE; + $ec = TalerErrorCode::tryFrom ($result['code']) ?? TalerErrorCode::TALER_EC_NONE; switch ($ec) { - case TALER_EC_NONE: + case TalerErrorCode::TALER_EC_NONE: // Protocol violation. Could happen if the backend domain was // taken over by someone else. $body_log = json_encode($result, 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 ?? 'N/A']); return FALSE; - case TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN: + 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' => $result['detail'] ?? 'N/A']); return FALSE; - case TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN: + case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN: // This could happen if the instance owner manually deleted // an order while the customer was looking at the article. $this->logger->warning('Order "@order" disappeared in the backend.', ['@order' => $order_id]); @@ -360,13 +362,16 @@ class TalerMerchantApiService { return FALSE; } - // FIXME: transition away from price to price categories... - $price = $node->get('field_price')->value; - if (empty($price)) { - $this->logger->debug('No price, cannot setup new order'); + /** @var TurnstilePriceCategory $price_category */ + $price_category = $node->get('field_turnstile_price_category'); + if (! $price_category) { + $this->logger->debug('No price category, cannot setup new order'); return FALSE; } + // FIXME: map price category to price(s)! + $price = "KUDOS:1"; + // FIXME: support v1 contract terms and use it // if we have multiple currencies in $price! // FIXME: add support for subscriptions diff --git a/src/TurnstileFieldManager.php b/src/TurnstileFieldManager.php @@ -49,7 +49,6 @@ class TurnstileFieldManager { continue; } - $this->addPriceField($bundle); $this->addPriceCategoryField($bundle); } } @@ -58,23 +57,6 @@ class TurnstileFieldManager { * Ensure field storage configurations exist. */ protected function ensureFieldStorageExists() { - // Create price field storage if it doesn't exist. - $field_price_storage = FieldStorageConfig::loadByName('node', 'field_price'); - if (!$field_price_storage) { - $field_price_storage = FieldStorageConfig::create([ - 'field_name' => 'field_price', - 'entity_type' => 'node', - 'type' => 'string', - 'cardinality' => 1, - 'settings' => [ - 'max_length' => 255, - 'case_sensitive' => FALSE, - 'is_ascii' => TRUE, - ], - ]); - $field_price_storage->save(); - } - // Create price category field storage if it doesn't exist. $field_category_storage = FieldStorageConfig::loadByName('node', 'field_turnstile_price_category'); if (!$field_category_storage) { @@ -92,64 +74,6 @@ class TurnstileFieldManager { } /** - * Add price field to a bundle. - * - * @param string $bundle - * The bundle machine name. - */ - protected function addPriceField($bundle) { - $field_price_storage = FieldStorageConfig::loadByName('node', 'field_price'); - - $existing_field = FieldConfig::loadByName('node', $bundle, 'field_price'); - if ($existing_field) { - // Ensure constraint is applied. - $constraints = $existing_field->getConstraints(); - if (!isset($constraints['TalerPriceListFormat'])) { - $constraints['TalerPriceListFormat'] = []; - $existing_field->set('constraints', $constraints); - $existing_field->save(); - } - } - else { - // Create field configuration. - $field_config = FieldConfig::create([ - 'field_storage' => $field_price_storage, - 'bundle' => $bundle, - 'label' => 'Price', - 'description' => 'Price for accessing this content (e.g., "EUR:5" or "USD:5,CHF:3.50" to support payment in multiple currencies)', - 'required' => FALSE, - ]); - $field_config->setConstraints(['TalerPriceListFormat' => []]); - $field_config->save(); - - // Add to form display. - $form_display = EntityFormDisplay::load('node.' . $bundle . '.default'); - if ($form_display) { - $form_display->setComponent('field_price', [ - 'type' => 'string_textfield', - 'weight' => 10, - 'settings' => [ - 'size' => 60, - 'placeholder' => 'e.g., EUR:1 or USD:5,CHF:3.50', - ], - ]); - $form_display->save(); - } - - // Add to view display. - $view_display = EntityViewDisplay::load('node.' . $bundle . '.default'); - if ($view_display) { - $view_display->setComponent('field_price', [ - 'type' => 'string', - 'weight' => 10, - 'label' => 'above', - ]); - $view_display->save(); - } - } - } - - /** * Add price category field to a bundle. * * @param string $bundle @@ -218,11 +142,6 @@ class TurnstileFieldManager { */ public function removeFieldsFromContentTypes(array $bundles) { foreach ($bundles as $bundle) { - $field_config = FieldConfig::loadByName('node', $bundle, 'field_price'); - if ($field_config) { - $field_config->delete(); - } - $field_config = FieldConfig::loadByName('node', $bundle, 'field_turnstile_price_category'); if ($field_config) { $field_config->delete(); @@ -231,7 +150,6 @@ class TurnstileFieldManager { // Remove from form display. $form_display = EntityFormDisplay::load('node.' . $bundle . '.default'); if ($form_display) { - $form_display->removeComponent('field_price'); $form_display->removeComponent('field_turnstile_price_category'); $form_display->save(); } @@ -239,7 +157,6 @@ class TurnstileFieldManager { // Remove from view display. $view_display = EntityViewDisplay::load('node.' . $bundle . '.default'); if ($view_display) { - $view_display->removeComponent('field_price'); $view_display->removeComponent('field_turnstile_price_category'); $view_display->save(); } @@ -252,19 +169,6 @@ class TurnstileFieldManager { * Clean up field storage if no content types are using it. */ protected function cleanupFieldStorage() { - $field_price_storage = FieldStorageConfig::loadByName('node', 'field_price'); - if ($field_price_storage) { - $field_configs = $this->entityTypeManager - ->getStorage('field_config') - ->loadByProperties([ - 'field_storage' => $field_price_storage, - ]); - - if (empty($field_configs)) { - $field_price_storage->delete(); - } - } - $field_category_storage = FieldStorageConfig::loadByName('node', 'field_turnstile_price_category'); if ($field_category_storage) { $field_configs = $this->entityTypeManager diff --git a/turnstile.module b/turnstile.module @@ -13,46 +13,8 @@ use GuzzleHttp\Exception\RequestException; /** - * Implements hook_entity_bundle_field_info_alter(). - * Adds the TalerPriceListFormat constraint to 'field_price' fields. - * Probably can be removed entirely once we have only price categories. - */ -function turnstile_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) { - $log_verbose = FALSE; - if ($entity_type->id() !== 'node') { - if ($log_verbose) { - \Drupal::logger('turnstile')->debug('Node ID "@id" is not one Turnstile cares about', [ - '@id' => $entity_type->id(), - ]); - } - return; - } - $config = \Drupal::config('turnstile.settings'); - $enabled_types = $config->get('enabled_content_types') ?: []; - - // Only show price field on enabled content types. - if (! in_array($bundle, $enabled_types)) { - if ($log_verbose) { - \Drupal::logger('turnstile')->debug('Content type "@bundle" is not one Turnstile cares about', [ - '@bundle' => $bundle, - ]); - } - return; - } - - if (isset($fields['field_price'])) { - // Use the ID as defined in the annotation of the constraint definition - $fields['field_price']->addConstraint('TalerPriceListFormat', []); - } else { - \Drupal::logger('turnstile')->debug('Node lacks field_price, strange. Not adding constraint.'); - } -} - - -/** * Implements hook_form_FORM_ID_alter() for node forms. Adds a - * description for the Turnstile fields. - * FIXME: is there a better way to do this? + * 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(); @@ -61,10 +23,6 @@ function turnstile_form_node_form_alter(&$form, \Drupal\Core\Form\FormStateInter // Only show price field on enabled content types. if (in_array($node->bundle(), $enabled_types)) { - if (isset($form['field_price'])) { - $form['field_price']['#group'] = 'meta'; - $form['field_price']['widget'][0]['value']['#description'] = t('Set a price to enable paywall protection for this content.'); - } if (isset($form['field_turnstile_price_category'])) { $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.'); @@ -106,8 +64,9 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent $node = $entity; // Check if the node has a price field and it's not empty. - if (!$node->hasField('field_price') || $node->get('field_price')->isEmpty()) { - \Drupal::logger('turnstile')->debug('Node has no price set, skipping Turnstile checks.'); + if (!$node->hasField('field_turnstile_price_category') || + $node->get('field_turnstile_price_category')->isEmpty()) { + \Drupal::logger('turnstile')->debug('Node has no price category set, skipping Turnstile checks.'); return; } @@ -117,7 +76,7 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent // Check if this node type is enabled for paywall. if (!in_array($node->bundle(), $enabled_types)) { - // This case is very strange: how can we have a field_price if turnstile is not enabled? + // This case is very strange: how can we have a field_turnstile_price_category if turnstile is not enabled? \Drupal::logger('turnstile')->error('Bundle has price but is not enabled with Turnstile, skipping payment.'); return; } @@ -349,85 +308,3 @@ function turnstile_theme() { ], ]; } - - - -/** - * Debugs a price category by outputting all its settings. - * - * @param string $price_category_id - * The price category identifier. - * - * @return string - * Formatted output of the price category settings. - */ -function debug_price_category($price_category_id) { - $output = []; - - // Load the price category. - $price_category = \Drupal::entityTypeManager() - ->getStorage('turnstile_price_category') - ->load($price_category_id); - - if (!$price_category) { - return "Price category '$price_category_id' not found.\n"; - } - - // Get API service for subscription and currency labels. - $api_service = \Drupal::service('turnstile.api_service'); - $subscriptions = $api_service->getSubscriptions(); - $currencies = $api_service->getCurrencies(); - - // Create lookup arrays for labels. - $subscription_labels = []; - foreach ($subscriptions as $sub) { - $id = $sub['id'] ?? $sub['name']; - $subscription_labels[$id] = $sub['label'] ?? $sub['name']; - } - - $currency_labels = []; - foreach ($currencies as $curr) { - $code = $curr['code'] ?? $curr['name']; - $currency_labels[$code] = $curr['label'] ?? $curr['code']; - } - - // Build output. - $output[] = "=== Price Category: {$price_category->label()} ==="; - $output[] = "ID: {$price_category->id()}"; - $output[] = "Description: {$price_category->getDescription()}"; - $output[] = ""; - $output[] = "Prices:"; - $output[] = str_repeat('-', 60); - - $prices = $price_category->getPrices(); - - if (empty($prices)) { - $output[] = " No prices configured."; - } - else { - foreach ($prices as $subscription_id => $currency_prices) { - $sub_label = $subscription_labels[$subscription_id] ?? $subscription_id; - $output[] = ""; - $output[] = " Subscription: $sub_label"; - - if (empty($currency_prices)) { - $output[] = " No prices set for this subscription."; - } - else { - foreach ($currency_prices as $currency_code => $price) { - $curr_label = $currency_labels[$currency_code] ?? $currency_code; - $output[] = " $curr_label ($currency_code): $price"; - } - } - } - } - - $output[] = str_repeat('=', 60); - - return implode("\n", $output) . "\n"; -} - -/** - * Usage example for drush: - * drush php-eval "echo debug_price_category('premium');" - */ -\ No newline at end of file