turnstile

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

commit 55dc15bccb7dbf97cae8ee45b237ecbe9018f8f7
parent 8b3a41b9ba801823fa3909605725c2afd0dbcc14
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu, 24 Jul 2025 22:14:01 +0200

fix up logic

Diffstat:
Msrc/Service/Turnstile.php | 113++-----------------------------------------------------------------------------
Mturnstile.module | 231++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
2 files changed, 137 insertions(+), 207 deletions(-)

diff --git a/src/Service/Turnstile.php b/src/Service/Turnstile.php @@ -9,6 +9,8 @@ use Psr\Log\LoggerInterface; /** * Service for handling paywall functionality. + * + * FIXME: is this class needed at all? */ class Turnstile { @@ -39,115 +41,4 @@ class Turnstile { $this->httpClient = $http_client; } - - - /** - * Check order status with Taler backend. - * - * @param string $order_id - * The order ID to check. - * - * @return array|FALSE - * Order status information or FALSE on failure. - */ - public function checkOrderStatus($order_id) { - $config = $this->configFactory->get('turnstile.settings'); - $backend_url = $config->get('payment_backend_url'); - $access_token = $config->get('access_token'); - - if (empty($backend_url) || empty($access_token)) { - return FALSE; - } - - try { - $response = $this->httpClient->get($backend_url . '/private/orders/' . $order_id, [ - 'headers' => [ - 'Authorization' => 'Bearer ' . $access_token, - ], - ]); - - // FIXME: check HTTP status first... - $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! - - 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', - ]; - } - catch (RequestException $e) { - \Drupal::logger('turnstile')->error('Failed to check order status: @message', ['@message' => $e->getMessage()]); - return FALSE; - } - } - - /** - * Create a new Taler order. - * - * @param \Drupal\node\NodeInterface $node - * The node to create an order for. - * - * @return array|FALSE - * Order information or FALSE on failure. - */ - public function createOrder($node) { - $config = $this->configFactory->get('turnstile.settings'); - $backend_url = $config->get('payment_backend_url'); - $access_token = $config->get('access_token'); - - if (empty($backend_url) || empty($access_token)) { - return FALSE; - } - - $price = $node->get('field_price')->value; - if (empty($price)) { - return FALSE; - } - - $fulfillment_url = $node->toUrl('canonical', ['absolute' => TRUE])->toString(); - $order_data = [ - 'order' => [ - 'amount' => $price, - 'summary' => 'Access to: ' . $node->getTitle(), - 'fulfillment_url' => $fulfillment_url, - ], - ], - 'create_token' => FALSE, - ]; - - try { - $response = $this->httpClient->post($backend_url . '/private/orders', [ - 'json' => $order_data, - 'headers' => [ - 'Authorization' => 'Bearer ' . $access_token, - 'Content-Type' => 'application/json', - ], - ]); - - // FIXME: check HTTP status first... - $result = json_decode($response->getBody(), TRUE); - - if (isset($result['order_id'])) { - // FIXME: Someone must set - // $_SESSION['turnstile_orders'][$node_id]['order_id'] = $result['order_id']; - return [ - 'order_id' => $result['order_id'], - 'payment_url' => $backend_url . '/orders/' . $result['order_id'], - 'paid' => FALSE, - 'refunded' => FALSE, - 'created' => time(), - ]; - } - } - catch (RequestException $e) { - \Drupal::logger('turnstile')->error('Failed to create Taler order: @message', ['@message' => $e->getMessage()]); - } - - return FALSE; - } - } \ No newline at end of file diff --git a/turnstile.module b/turnstile.module @@ -8,6 +8,7 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\Display\EntityViewDisplayInterface; use Drupal\node\NodeInterface; +use GuzzleHttp\Exception\RequestException; /** * Implements hook_entity_view_alter(). @@ -33,7 +34,7 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent } // Get the original body content. - if (! $entity->hasField('body')) { + if (!$entity->hasField('body')) { return; } if ($entity->get('body')->isEmpty()) { @@ -48,7 +49,6 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent if (isset($build['body'][0]['#text'])) { $build['body'][0]['#text'] = $transformed_body; } - } @@ -57,11 +57,13 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent * * @param string $body * The original body content. + * @param \Drupal\node\NodeInterface $node + * The node entity. * * @return string * The transformed body content. */ -function turnstile_check_paywall_cookie($body) { +function turnstile_check_paywall_cookie($body, NodeInterface $node) { // Start session if not already started. if (session_status() == PHP_SESSION_NONE) { session_start(); @@ -78,10 +80,11 @@ function turnstile_check_paywall_cookie($body) { // Check if there's an existing order for this article. $node_id = $node ? $node->id() : NULL; - if (! $node_id) { + if (!$node_id) { // Strange, but not a case where we can do a payment. return $body; } + if (isset($_SESSION['turnstile_orders'][$node_id])) { $order = $_SESSION['turnstile_orders'][$node_id]; @@ -95,27 +98,55 @@ function turnstile_check_paywall_cookie($body) { // Check order status with backend if not marked as paid. if (!$order['paid']) { - $status = turnstile_checkOrderStatus($order['order_id']); + $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! $_SESSION['turnstile_orders'][$node_id]['paid'] = TRUE; - return TRUE; + return $body; } } } - - // No valid payment found, need to create order or show payment option. $order_info = turnstile_create_or_get_order($node); + if (! $order_info) { + // Fallback: just return body, not good to have unhappy readers. + return $body; + } + + return turnstile_render_preview_with_payment_button($body, $order_info); +} + + +/** + * Create or get existing order for a node. + * + * @param \Drupal\node\NodeInterface $node + * The node to create an order for. + * + * @return array|FALSE + * Order information or FALSE on failure. + */ +function turnstile_create_or_get_order(NodeInterface $node) { + $node_id = $node->id(); + + // 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! + return $_SESSION['turnstile_orders'][$node_id]; + } + + // Create new order + $order_info = turnstile_create_order($node); if ($order_info) { - return turnstile_render_preview_with_payment_button($body, $order_info); + // Store order in session + $_SESSION['turnstile_orders'][$node_id] = $order_info; + return $order_info; } - // Fallback - truncate content with safe HTML handling. - return turnstile_truncate_content_safely($body); + return FALSE; } @@ -130,7 +161,7 @@ function turnstile_check_paywall_cookie($body) { * @return string * Safely truncated HTML. */ -function paywall_guard_truncate_content_safely($content, $length = 200) { +function turnstile_truncate_content_safely($content, $length = 200) { // Remove HTML tags for length calculation. $text_only = strip_tags($content); @@ -140,7 +171,9 @@ function paywall_guard_truncate_content_safely($content, $length = 200) { // 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; @@ -186,23 +219,22 @@ function paywall_guard_truncate_content_safely($content, $length = 200) { * @return string * Rendered preview with payment button. */ -function paywall_guard_render_preview_with_payment_button($body, $order_info) { +function turnstile_render_preview_with_payment_button($body, $order_info) { // Safely truncate content to first few lines. - $preview = paywall_guard_truncate_content_safely($body, 200); + $preview = turnstile_truncate_content_safely($body, 200); + // 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>This content is protected by a paywall.</strong></p>'; - $payment_button .= '<p>Continue reading by completing your payment.</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;">Pay to Read More</a>'; + $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 $preview . $payment_button; } - - - /** * Implements hook_form_FORM_ID_alter() for node forms. */ @@ -230,39 +262,46 @@ function turnstile_form_node_form_alter(&$form, \Drupal\Core\Form\FormStateInter * @return array|FALSE * Order status information or FALSE on failure. */ -function turnstile_checkOrderStatus($order_id) { - $config = $this->configFactory->get('turnstile.settings'); - $backend_url = $config->get('payment_backend_url'); - $access_token = $config->get('access_token'); +function turnstile_check_order_status($order_id) { + $config = \Drupal::config('turnstile.settings'); + $backend_url = $config->get('payment_backend_url'); + $access_token = $config->get('access_token'); + + if (empty($backend_url) || empty($access_token)) { + return FALSE; + } + + try { + $http_client = \Drupal::httpClient(); + $response = $http_client->get($backend_url . '/private/orders/' . $order_id, [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + ], + ]); - if (empty($backend_url) || empty($access_token)) { + // Check HTTP status + if ($response->getStatusCode() !== 200) { return FALSE; } - try { - $response = $this->httpClient->get($backend_url . '/private/orders/' . $order_id, [ - 'headers' => [ - 'Authorization' => 'Bearer ' . $access_token, - ], - ]); - - // FIXME: check HTTP status first... - $result = json_decode($response->getBody(), TRUE); + $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! + // FIXME: check $result['contract_terms'] to see if this is + // a valid *subscription*, and if so, return subscription 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', - ]; - } - catch (RequestException $e) { - \Drupal::logger('turnstile')->error('Failed to check order status: @message', ['@message' => $e->getMessage()]); - return FALSE; - } + 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! + ]; + } + catch (RequestException $e) { + \Drupal::logger('turnstile')->error('Failed to check order status: @message', ['@message' => $e->getMessage()]); + return FALSE; + } } @@ -275,64 +314,65 @@ function turnstile_checkOrderStatus($order_id) { * @return array|FALSE * Order information or FALSE on failure. */ -public function turnstile_createOrder($node) { - $config = $this->configFactory->get('turnstile.settings'); - $backend_url = $config->get('payment_backend_url'); - $access_token = $config->get('access_token'); +function turnstile_create_order(NodeInterface $node) { + $config = \Drupal::config('turnstile.settings'); + $backend_url = $config->get('payment_backend_url'); + $access_token = $config->get('access_token'); - if (empty($backend_url) || empty($access_token)) { - return FALSE; - } + if (empty($backend_url) || empty($access_token)) { + return FALSE; + } - $price = $node->get('field_price')->value; - if (empty($price)) { - return FALSE; - } + $price = $node->get('field_price')->value; + if (empty($price)) { + return FALSE; + } + + $fulfillment_url = $node->toUrl('canonical', ['absolute' => TRUE])->toString(); + $order_data = [ + 'order' => [ + 'amount' => $price, + 'summary' => 'Access to: ' . $node->getTitle(), + 'fulfillment_url' => $fulfillment_url, + ], + 'create_token' => FALSE, + ]; - $fulfillment_url = $node->toUrl('canonical', ['absolute' => TRUE])->toString(); - $order_data = [ - 'order' => [ - 'amount' => $price, - 'summary' => 'Access to: ' . $node->getTitle(), - 'fulfillment_url' => $fulfillment_url, - ], + try { + $http_client = \Drupal::httpClient(); + $response = $http_client->post($backend_url . '/private/orders', [ + 'json' => $order_data, + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + 'Content-Type' => 'application/json', ], - 'create_token' => FALSE, - ]; + ]); - try { - $response = $this->httpClient->post($backend_url . '/private/orders', [ - 'json' => $order_data, - 'headers' => [ - 'Authorization' => 'Bearer ' . $access_token, - 'Content-Type' => 'application/json', - ], - ]); - - // FIXME: check HTTP status first... - $result = json_decode($response->getBody(), TRUE); - - if (isset($result['order_id'])) { - // FIXME: Someone must set - // $_SESSION['turnstile_orders'][$node_id]['order_id'] = $result['order_id']; - return [ - 'order_id' => $result['order_id'], - 'payment_url' => $backend_url . '/orders/' . $result['order_id'], - 'paid' => FALSE, - 'refunded' => FALSE, - 'created' => time(), - ]; - } + // Check HTTP status + if ($response->getStatusCode() !== 200 && $response->getStatusCode() !== 201) { + return FALSE; } - catch (RequestException $e) { - \Drupal::logger('turnstile')->error('Failed to create Taler order: @message', ['@message' => $e->getMessage()]); + + $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! + ]; } + } + catch (RequestException $e) { + \Drupal::logger('turnstile')->error('Failed to create Taler order: @message', ['@message' => $e->getMessage()]); + } - return FALSE; + return FALSE; } - /** * Implements hook_theme(). */ @@ -344,4 +384,4 @@ function turnstile_theme() { ], ], ]; -} -\ No newline at end of file +}