turnstile

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

commit a54d890e6fe1e2338a7b7823fb1f928699130618
parent 33f75c04c03b343655f3b695acdae770fea4ff47
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  9 Oct 2025 12:30:59 +0200

first kind-of working version

Diffstat:
Mturnstile.module | 400++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
1 file changed, 223 insertions(+), 177 deletions(-)

diff --git a/turnstile.module b/turnstile.module @@ -17,10 +17,13 @@ use GuzzleHttp\Exception\RequestException; * Implements hook_entity_bundle_field_info_alter(). */ function turnstile_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) { + $log_verbose = FALSE; if ($entity_type->id() !== 'node') { - \Drupal::logger('turnstile')->debug('Node ID "@id" is not one Turnstile cares about', [ - '@id' => $entity_type->id(), - ]); + 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'); @@ -28,9 +31,11 @@ function turnstile_entity_bundle_field_info_alter(&$fields, EntityTypeInterface // Only show price field on enabled content types. if (! in_array($bundle, $enabled_types)) { - \Drupal::logger('turnstile')->debug('Content type "@bundle" is not one Turnstile cares about', [ - '@bundle' => $bundle, - ]); + if ($log_verbose) { + \Drupal::logger('turnstile')->debug('Content type "@bundle" is not one Turnstile cares about', [ + '@bundle' => $bundle, + ]); + } return; } @@ -48,9 +53,8 @@ function turnstile_entity_bundle_field_info_alter(&$fields, EntityTypeInterface * Implements hook_entity_view_alter(). */ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) { - // Only process nodes. if (!$entity instanceof NodeInterface) { - \Drupal::logger('turnstile')->debug('Not a node, skipping Turnstile checks.'); + // Turnstile only processes nodes. return; } /** @var \Drupal\node\NodeInterface $node */ @@ -58,7 +62,7 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent // 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, skipping Turnstile checks.'); + \Drupal::logger('turnstile')->debug('Node has no price set, skipping Turnstile checks.'); return; } @@ -68,9 +72,8 @@ 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)) { - // NOTE: This case is very strange: how can we have a field_price if - // turnstile is not enabled? - \Drupal::logger('turnstile')->debug('Bundle not enabled with Turnstile, skipping payment.'); + // This case is very strange: how can we have a field_price if turnstile is not enabled? + \Drupal::logger('turnstile')->error('Bundle has price but is not enabled with Turnstile, skipping payment.'); return; } @@ -91,16 +94,8 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent } \Drupal::logger('turnstile')->debug('Checking for payment...'); - // Check if user is a subscriber. - // NOTE: setting subscriber status is not yet supported! - if (isset($_SESSION['turnstile_subscriber']) && - $_SESSION['turnstile_subscriber'] === TRUE) { - // FIXME: should set turnstile_subscriber to *expiration* time - // of the subscription and check not for TRUE but for "greater - // current time". - // FIXME: For now, subscribers have full access to everything - // in the future, we may want to have 50%-off subscriptions - // and others where we need to check the exact type of subscription. + // Check if user has already paid for a non-expired subscription. + if (time() < $_SESSION['turnstile_subscriber'] ?? 0) { \Drupal::logger('turnstile')->debug('Subscriber cookie detected, granting access.'); return; } @@ -116,36 +111,30 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent if (isset($_SESSION['turnstile_orders'][$node_id])) { $order = $_SESSION['turnstile_orders'][$node_id]; - // Check if order is paid and not refunded. - // FIXME: refunds are not really supported, do we care to - // keep support for them here? - if (isset($order['paid']) && - (TRUE === $order['paid']) && - ( (! isset($order['refunded'])) || - (FALSE === $order['refunded'])) ) { + // Check if order is paid. + if ($order['paid'] ?? FALSE) { \Drupal::logger('turnstile')->debug('Paid order detected, passing Turnstile.'); return; } // Check order status with backend if not marked as paid. - if (!$order['paid']) { - \Drupal::logger('turnstile')->debug('Checking order status ...'); - $status = turnstile_check_order_status($order['order_id']); - if ($status && $status['paid']) { - // Update session with new status. - // FIXME: if we keep refunds above, we should check - // for refunds here as well! - \Drupal::logger('turnstile')->debug('Updating order status to paid.'); - $_SESSION['turnstile_orders'][$node_id]['paid'] = TRUE; - return; + \Drupal::logger('turnstile')->debug('Checking order status ...'); + $status = turnstile_check_order_status($order['order_id']); + if ($status && $status['paid']) { + // Update session with new status. + \Drupal::logger('turnstile')->debug('Updating order status to paid.'); + $_SESSION['turnstile_orders'][$node_id]['paid'] = TRUE; + if ($status['subscription_expiration'] > time()) { + \Drupal::logger('turnstile')->debug('Paid subscription detected, enabling subscription mode.'); + $_SESSION['turnstile_subscriber'] = $status['subscription_expiration']; } + return; } - } + } // end case where we had an order stored in the session // No valid payment found, need to create order or show payment option. $order_info = turnstile_create_or_get_order($node); if (! $order_info) { - \Drupal::logger('turnstile')->debug('Failed to create order.'); // Fallback: just return body, not good to have unhappy readers. $config = \Drupal::config('turnstile.settings'); $grant_access_on_error = $config->get('grant_access_on_error') ?: true; @@ -159,12 +148,18 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent \Drupal::logger('turnstile')->debug('Rendering page with payment request.'); - _turnstile_transform_body_recursive ($build, $node, $order_info); + // Disable page cache, this page is going to be personalized. + \Drupal::service('page_cache_kill_switch')->trigger(); - // FIXME: we probably need to invalidate caches using - // something like this (untested!) - // $build['body']['#cache']['contexts'][] = 'user.permissions'; + _turnstile_transform_body_recursive ($build, $node, $order_info); + // We could now set the HTTP header to trigger the Taler-wallet + // immediately. However, this will prevent the user from seeing + // the preview (other than in the summary text of the order). + // FIXME: enable/disable setting the header based on a site-wide + // policy option! + // Set the Taler:-HTTP header with the response to the client! + header('XX-Taler: xtaler://pay/FIXME'); // For now, use broken header... } @@ -197,14 +192,13 @@ function _turnstile_transform_body_recursive(array &$build, NodeInterface $node, $key === 'body' ) { if ($node->hasField('body') && !$node->get('body')->isEmpty()) { + \Drupal::logger('turnstile')->debug('Generating payment request body'); $body_field = $node->get('body')->first(); $summary = $body_field->summary; $full = $body_field->value; $format = $body_field->format; - // FIXME: use text_summary, or - // maybe better turnstile_truncate_content_safely? Not sure! $replacement_text = $summary ?: text_summary($full, $format, 300); // Replace with processed summary or trimmed text. @@ -226,6 +220,28 @@ function _turnstile_transform_body_recursive(array &$build, NodeInterface $node, /** + * Render payment button. + * + * @param array $order_info + * Order information. + * + * @return string + * Rendered payment button. + */ +function turnstile_render_payment_button($order_info) { + // FIXME: We should modify this to + // inline the payment dialog / QR code and long-poll + // on the order status on this page! + + $payment_button = '<div class="paywall-payment-section" style="margin-top: 20px; padding: 20px; border: 1px solid #ddd; background: #f9f9f9;">'; + $payment_button .= '<p><strong>Please pay with GNU Taler to continue reading.</strong></p>'; + $payment_button .= '<a href="' . htmlspecialchars($order_info['payment_url']) . '" class="button btn btn-primary" style="display: inline-block; padding: 10px 20px; background: #007cba; color: white; text-decoration: none; border-radius: 4px;">Click here to open payment dialog</a>'; + $payment_button .= '</div>'; + return $payment_button; +} + + +/** * Create or get existing order for a node. * * @param \Drupal\node\NodeInterface $node @@ -239,106 +255,24 @@ function turnstile_create_or_get_order(NodeInterface $node) { // Check if we already have an order for this node in session if (isset($_SESSION['turnstile_orders'][$node_id])) { - // FIXME: check of order expired, not useful if payment deadline expired! - \Drupal::logger('turnstile')->debug('Re-using existing order.'); - return $_SESSION['turnstile_orders'][$node_id]; + $order_info = $_SESSION['turnstile_orders'][$node_id]; + $order_expiration = $order_info['order_expiration'] ?? 0; + if ($order_expiration > time()) { + \Drupal::logger('turnstile')->debug('Re-using existing order.'); + return $_SESSION['turnstile_orders'][$node_id]; + } + \Drupal::logger('turnstile')->debug('Existing order payment deadline expired, creating a new one.'); } // Create new order $order_info = turnstile_create_order($node); - if ($order_info) { - // Store order in session - $_SESSION['turnstile_orders'][$node_id] = $order_info; - \Drupal::logger('turnstile')->debug('Created new order.'); - return $order_info; - } - \Drupal::logger('turnstile')->debug('Failed to create order.'); - return FALSE; -} - - -/** - * Safely truncate HTML content. - * - * @param string $content - * The HTML content to truncate. - * @param int $length - * Maximum length in characters. - * - * @return string - * Safely truncated HTML. - */ -function turnstile_truncate_content_safely($content, $length = 200) { - // Remove HTML tags for length calculation. - $text_only = strip_tags($content); - - if (strlen($text_only) <= $length) { - \Drupal::logger('turnstile')->debug('Body too short, skipping payment transformation.'); - return $content; - } - - // Use DOMDocument to safely truncate HTML. - $dom = new \DOMDocument(); - libxml_use_internal_errors(true); - $dom->loadHTML('<?xml encoding="UTF-8">' . $content, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); - libxml_clear_errors(); - - $truncated = ''; - $current_length = 0; - - foreach ($dom->childNodes as $node) { - if ($current_length >= $length) { - break; - } - - $node_text = $node->textContent; - if ($current_length + strlen($node_text) > $length) { - // Truncate this node. - $remaining = $length - $current_length; - $truncated_text = substr($node_text, 0, $remaining) . '...'; - - if ($node->nodeType === XML_TEXT_NODE) { - $truncated .= htmlspecialchars($truncated_text); - } else { - // For element nodes, create a copy with truncated text. - $temp_node = $node->cloneNode(FALSE); - $temp_node->textContent = $truncated_text; - $truncated .= $dom->saveHTML($temp_node); - } - break; - } else { - $truncated .= $dom->saveHTML($node); - $current_length += strlen($node_text); - } + if (! $order_info) { + return FALSE; } - \Drupal::logger('turnstile')->debug('Returning truncated body.'); - - return $truncated; -} - - -/** - * Render payment button. - * - * @param array $order_info - * Order information. - * - * @return string - * Rendered payment button. - */ -function turnstile_render_payment_button($order_info) { - \Drupal::logger('turnstile')->debug('Generating payment request body'); - - // FIXME: eventually, we may want to at least give the option to - // inline the payment dialog / QR code and long-poll ourselves, - // and/or to send the Taler:-HTTP header with the response to the client! - // Add payment button. - $payment_button = '<div class="paywall-payment-section" style="margin-top: 20px; padding: 20px; border: 1px solid #ddd; background: #f9f9f9;">'; - $payment_button .= '<p><strong>Please pay with GNU Taler to continue reading.</strong></p>'; - $payment_button .= '<a href="' . htmlspecialchars($order_info['payment_url']) . '" class="button btn btn-primary" style="display: inline-block; padding: 10px 20px; background: #007cba; color: white; text-decoration: none; border-radius: 4px;">Click here to open payment dialog</a>'; - $payment_button .= '</div>'; - - return $payment_button; + // Store order in session + $_SESSION['turnstile_orders'][$node_id] = $order_info; + \Drupal::logger('turnstile')->debug('Created new order.'); + return $order_info; } @@ -374,8 +308,9 @@ function turnstile_check_order_status($order_id) { $backend_url = $config->get('payment_backend_url'); $access_token = $config->get('access_token'); - if (empty($backend_url) || empty($access_token)) { - \Drupal::logger('turnstile')->debug('No backend, cannot check order status'); + if (empty($backend_url) || + empty($access_token)) { + \Drupal::logger('turnstile')->debug('No Turnstile backend configured, cannot check order status!'); return FALSE; } @@ -385,30 +320,102 @@ function turnstile_check_order_status($order_id) { 'headers' => [ 'Authorization' => 'Bearer ' . $access_token, ], + // Do not throw exceptions on 4xx/5xx status codes + 'http_errors' => false, ]); - // Check HTTP status - if ($response->getStatusCode() !== 200) { - // FIXME: treat expired/deleted orders differently! - \Drupal::logger('turnstile')->debug('Failed to obtain order status'); - return FALSE; + $http_status = $response->getStatusCode(); + $body = $response->getBody(); + $result = json_decode($body, TRUE); + switch ($http_status) + { + case 200: + // Success, handled below + break; + // FIXME: also handle unauthorized! + case 404: + // Order unknown or instance unknown + $ec = $result['code'] ?? 0; + switch ($ec) + { + case 0: + // 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); + \Drupal::logger('turnstile')->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 2000: // FIXME: MERCHANT_GENERIC_INSTANCE_UNKNOWN + // This could happen if our instance was deleted after the configuration was + // checked. Very bad, log serious error. + \Drupal::logger('turnstile')->error('Configured instance "@detail" unknown to merchant backend. Check your Turnstile configuration!', ['@detail' => $result['detail'] ?? 'N/A']); + return FALSE; + case 2005: // FIXME: MERCHANT_GENERIC_ORDER_UNKNOWN + // This could happen if the instance owner manually deleted + // an order while the customer was looking at the article. + \Drupal::logger('turnstile')->warning('Order "@order" disappeared in the backend.', ['@order' => $order_id]); + return FALSE; + default: + $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + \Drupal::logger('turnstile')->error('Unexpected error code @ec with HTTP status code @status from Taler merchant backend when trying to get order status: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@detail' => $result['detail'] ?? 'N/A', '@body' => $body_log ?? 'N/A']); + return FALSE; + } + default: + // Internal server errors and the like... + $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + \Drupal::logger('turnstile')->error('Unexpected HTTP status code @status from Taler merchant backend when trying to get order status: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@detail' => $result['detail'] ?? 'N/A', '@body' => $body_log ?? 'N/A']); + return FALSE; } - $result = json_decode($response->getBody(), TRUE); - - // FIXME: check $result['contract_terms'] to see if this is - // a valid *subscription*, and if so, return subscription status! - + $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + \Drupal::logger('turnstile')->error('Got existing contract: @body', ['@body' => $body_log ?? 'N/A']); + + $order_status = $result['order_status'] ?? 'unknown'; + $subscription_expiration = 0; + $pay_deadline = 0; + $paid = FALSE; + switch ($order_status) + { + case 'unpaid': + // 'pay_deadline' is only available since v21 rev 1, so for now we + // fall back to creation_time + offset. + $pay_deadline = $result['pay_deadline']['t_s'] ?? + 60 * 60 * 24 + $result['creation_time']['t_s'] ?? 0; + break; + case 'claimed': + $contract_terms = $result['contract_terms']; + $pay_deadline = $contract_terms['pay_deadline']['t_s'] ?? 0; + break; + case 'paid': + $paid = TRUE; + $contract_terms = $result['contract_terms']; + $contract_version = $result['version'] ?? 0; + switch ($contract_version) { + case 0: + break; + case 1: + $choice_index = $result['choice_index'] ?? 0; + $contract_choice = $contract_terms['choices'][$choice_index]; + $outputs = $contract_choice['outputs']; + // FIXME: add logic to detect subscriptions here and + // update $subscription_expiration if one was found! + break; + default: + break; + } // switch on contract version + break; + default: + \Drupal::logger('turnstile')->error('Got unexpected order status "@status"', ['@status' => $order_status]); + break; + } // switch on $order_status return [ 'order_id' => $order_id, - 'paid' => isset($result['order_status']) && $result['order_status'] === 'paid', - 'refunded' => isset($result['refunded']) && $result['refunded'] === TRUE, - 'order_status' => $result['order_status'] ?? 'unknown', - // FIXME: initialize 'created'? - // FIXME: probably better keep when offer expires! + 'paid' => $paid, + 'subscription_expiration' => $subscription_expiration, + 'order_expiration' => $pay_deadline, ]; } catch (RequestException $e) { + // Any kind of error that is outside of the spec. \Drupal::logger('turnstile')->error('Failed to check order status: @message', ['@message' => $e->getMessage()]); return FALSE; } @@ -434,19 +441,30 @@ function turnstile_create_order(NodeInterface $node) { return FALSE; } + // FIXME: transition away from price to price categories... $price = $node->get('field_price')->value; if (empty($price)) { \Drupal::logger('turnstile')->debug('No price, cannot setup new order'); return FALSE; } - // FIXME: support v1 contract terms and use it if we have multiple currencies in $price! + // FIXME: support v1 contract terms and use it + // if we have multiple currencies in $price! + // FIXME: add support for subscriptions $fulfillment_url = $node->toUrl('canonical', ['absolute' => TRUE])->toString(); + /* one day from now */ + // FIXME: after Merchant v1.1 we can use the returned + // the expiration time and then rely on the default already set in + // the merchant backend instead of hard-coding 1 day here! + $order_expiration = time() + 60 * 60 * 24; $order_data = [ 'order' => [ 'amount' => $price, 'summary' => 'Access to: ' . $node->getTitle(), 'fulfillment_url' => $fulfillment_url, + 'pay_deadline' => [ + 't_s' => $order_expiration, + ], ], 'create_token' => FALSE, ]; @@ -459,29 +477,57 @@ function turnstile_create_order(NodeInterface $node) { 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json', ], + // Do not throw exceptions on 4xx/5xx status codes + 'http_errors' => false, ]); - - // Check HTTP status - if ($response->getStatusCode() !== 200 && $response->getStatusCode() !== 201) { - \Drupal::logger('turnstile')->debug('Backend failed to setup new order'); + /* Get JSON result parsed as associative array */ + $http_status = $response->getStatusCode(); + $body = $response->getBody(); + $result = json_decode($body, TRUE); + switch ($http_status) + { + case 200: + case 201: /* 201 is not in-spec, but tolerated for now */ + if (! isset($result['order_id'])) { + \Drupal::logger('turnstile')->error('Failed to create order: HTTP success response unexpectedly lacks "order_id" field.'); + return FALSE; + } + /* Success, handled below */ + break; + // FIXME: also handle unauthorized! + case 404: + // FIXME: go into details on why we could get 404 here... + $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + \Drupal::logger('turnstile')->error('Failed to create order: @hint (@ec): @body', ['@hint' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@body' => $body_log ?? 'N/A']); + 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 */ + $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + \Drupal::logger('turnstile')->error('Unexpected HTTP status code @status trying to create order: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@detail' => $result['detail'] ?? 'N/A', '@body' => $body_log ?? 'N/A']); + return FALSE; + case 451: + /* KYC required, can happen, warn */ + \Drupal::logger('turnstile')->warning('Failed to create order as legitimization is required first. Please check legitimization status in your merchant backend.'); + return FALSE; + default: + $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + \Drupal::logger('turnstile')->error('Unexpected HTTP status code @status trying to create order: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@detail' => $result['detail'] ?? 'N/A', '@body' => $body_log ?? 'N/A']); return FALSE; } - $result = json_decode($response->getBody(), TRUE); - - if (isset($result['order_id'])) { - return [ - 'order_id' => $result['order_id'], - 'payment_url' => $backend_url . 'orders/' . $result['order_id'], - 'paid' => FALSE, - 'refunded' => FALSE, - 'created' => time(), // FIXME: probably better keep when offer expires! - ]; - } - \Drupal::logger('turnstile')->debug('Unexpected error trying to setup new order: no order_id returned'); + $order_id = $result['order_id']; + return [ + 'order_id' => $result['order_id'], + 'payment_url' => $backend_url . 'orders/' . $result['order_id'], + 'order_expiration' => $order_expiration, + 'paid' => FALSE, + ]; } catch (RequestException $e) { - \Drupal::logger('turnstile')->error('Failed to create Taler order: @message', ['@message' => $e->getMessage()]); + $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + \Drupal::logger('turnstile')->error('Failed to create Taler order: @message: @body', ['@message' => $e->getMessage(), '@body' => $body_log ?? 'N/A']); } return FALSE;