turnstile

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

commit 8e3a77bd2c1a9982692b42091ebf6d022a8f1c4e
parent 043b5e6d28b86825c0a1495553e572c7d5c7e87f
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  2 Oct 2025 23:29:30 +0200

add debug controller and TalerPriceListFormat validator (not yet working nicely)

Diffstat:
Asrc/Controller/DebugController.php | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Plugin/Validation/Constraint/TalerPriceListFormatConstraint.php | 27+++++++++++++++++++++++++++
Asrc/Plugin/Validation/Constraint/TalerPriceListFormatConstraintValidator.php | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mturnstile.routing.yml | 12++++++++++--
4 files changed, 250 insertions(+), 2 deletions(-)

diff --git a/src/Controller/DebugController.php b/src/Controller/DebugController.php @@ -0,0 +1,61 @@ +<?php + +namespace Drupal\turnstile\Controller; +use Drupal\Core\Controller\ControllerBase; + +use Drupal\Core\Form\ConfigFormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\field\Entity\FieldStorageConfig; +use Drupal\field\Entity\FieldConfig; +use Drupal\Core\Entity\Entity\EntityFormDisplay; +use Drupal\Core\Entity\Entity\EntityViewDisplay; +use Symfony\Component\DependencyInjection\ContainerInterface; + + +/** + * Turnstile debug controller. + */ +class DebugController extends ControllerBase { + + + /** + * Returns a renderable array for a test page. + * + * return [] + */ + public function content() { + + +$config = \Drupal::config('turnstile.settings'); +$enabled_types = $config->get('enabled_content_types') ?: []; + +foreach ($enabled_types as $bundle) { + $field_config = \Drupal\field\Entity\FieldConfig::loadByName('node', $bundle, 'field_price'); + if ($field_config) { + $field_config->setConstraints(['TalerPriceListFormat' => []]); + $field_config->save(); + } +} + + // Temporary debugging code - add to a hook or controller + $constraint_manager = \Drupal::service('validation.constraint'); + $definitions = $constraint_manager->getDefinitions(); + $build = [ + '#markup' => $this->t(isset($definitions['TalerPriceListFormat']) ? 'ok' : 'bad'), + ]; + + $field_config = FieldConfig::loadByName('node', 'article', 'field_price'); + if ($field_config) { + $constraints = $field_config->getConstraints(); + $build = [ + '#markup' => $this->t('constrained' . implode (",", $constraints)), + ]; + + } + + return $build; + } + +} +\ No newline at end of file diff --git a/src/Plugin/Validation/Constraint/TalerPriceListFormatConstraint.php b/src/Plugin/Validation/Constraint/TalerPriceListFormatConstraint.php @@ -0,0 +1,26 @@ +<?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. Please only use supported currencies.'; + + 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 @@ -0,0 +1,150 @@ +<?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)) { + return; + } + + $pricelist = $item->value; + + // Empty is OK, simply gratis. + if (empty($pricelist)) { + 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) { + $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)); + + foreach ($amounts as $amount) { + // Extract currency from each amount (format: CURRENCY:NUMBER[.FRACTION]). + if (preg_match('/^([A-Za-z]{1,11}):/', $amount, $matches)) { + $currency = $matches[1]; + + if (!$this->checkCurrency($currency, $backend_config)) { + $this->context->addViolation($constraint->invalidCurrency, [ + '@currency' => $currency + ]); + return; + } + } + } + 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; + } + } + + + /** + * Check if a currency is supported by the merchant backend. + * + * @param string $currency + * The currency code to check. + * @param array $backend_config + * The decoded JSON configuration from the payment backend. + * + * @return bool + * TRUE if the currency is supported, FALSE otherwise. + */ + protected function checkCurrency($currency, array $backend_config) { + $currencies = $backend_config['currencies']; + if (! isset($currencies[strtoupper($currency)])) + return FALSE; + return TRUE; + } + +} +\ No newline at end of file diff --git a/turnstile.routing.yml b/turnstile.routing.yml @@ -6,4 +6,12 @@ turnstile.settings: requirements: _permission: 'administer Turnstile' options: - _admin_route: TRUE -\ No newline at end of file + _admin_route: TRUE + +turnstile.debughook: + path: '/turndebug' + defaults: + _controller: '\Drupal\turnstile\Controller\DebugController::content' + _title: 'Turnstile Debugger' + requirements: + _permission: 'access content' +\ No newline at end of file