turnstile

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

commit 8b3a41b9ba801823fa3909605725c2afd0dbcc14
parent 06317974c88dd30b2034dc641864fadd491406c3
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu, 24 Jul 2025 01:13:11 +0200

refactor...

Diffstat:
Msrc/Service/Turnstile.php | 52----------------------------------------------------
Mturnstile.info.yml | 2+-
Mturnstile.module | 283++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mturnstile.permissions.yml | 2+-
4 files changed, 268 insertions(+), 71 deletions(-)

diff --git a/src/Service/Turnstile.php b/src/Service/Turnstile.php @@ -40,58 +40,6 @@ class Turnstile { } - /** - * Check if user has access to content. - * - * @param string $node_id - * The node ID. - * - * @return bool - * TRUE if user has access, FALSE otherwise. - */ - public function hasAccess($node_id) { - // Start session if not already started. - if (session_status() == PHP_SESSION_NONE) { - session_start(); - } - - // Check if user is a subscriber. - 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". - // Subscribers have full access to everything - return TRUE; - } - - // Check session-based orders. - 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']) && $order['paid'] === TRUE && - (!isset($order['refunded']) || $order['refunded'] === FALSE)) { - return TRUE; - } - - // Check order status with backend if not marked as paid. - if (!$order['paid']) { - $status = $this->checkOrderStatus($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; - } - } - } - - // User does NOT have access, caller should create order. - return FALSE; - } /** * Check order status with Taler backend. diff --git a/turnstile.info.yml b/turnstile.info.yml @@ -1,6 +1,6 @@ name: Turnstile type: module -description: 'Adds price field to nodes and implements paywall functionality with configurable content transformation.' +description: 'Adds price field to nodes and requires payment for access.' core_version_requirement: ^9 || ^10 package: System dependencies: diff --git a/turnstile.module b/turnstile.module @@ -18,6 +18,11 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent return; } + // Check if the node has a price field and it's not empty. + if (!$entity->hasField('field_price') || $entity->get('field_price')->isEmpty()) { + return; + } + // Get module configuration. $config = \Drupal::config('turnstile.settings'); $enabled_types = $config->get('enabled_content_types') ?: []; @@ -27,25 +32,26 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent return; } - // Check if the node has a price field and it's not empty. - if (!$entity->hasField('field_price') || $entity->get('field_price')->isEmpty()) { + // Get the original body content. + if (! $entity->hasField('body')) { + return; + } + if ($entity->get('body')->isEmpty()) { return; } - // Get the original body content. - if ($entity->hasField('body') && !$entity->get('body')->isEmpty()) { - $body_value = $entity->get('body')->value; - - // Call the paywall check function. - $transformed_body = turnstile_check_paywall_cookie($body_value); + $body_value = $entity->get('body')->value; + // Call the paywall check function with the node. + $transformed_body = turnstile_check_paywall_cookie($body_value, $entity); - // Update the body in the build array. - if (isset($build['body'][0]['#text'])) { - $build['body'][0]['#text'] = $transformed_body; - } + // Update the body in the build array. + if (isset($build['body'][0]['#text'])) { + $build['body'][0]['#text'] = $transformed_body; } + } + /** * Custom function to check paywall cookie and transform content. * @@ -56,17 +62,147 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent * The transformed body content. */ function turnstile_check_paywall_cookie($body) { - // Check if the payment cookie is set. - if (isset($_COOKIE['payment_cookie'])) { - // User has paid, return original content. + // Start session if not already started. + if (session_status() == PHP_SESSION_NONE) { + session_start(); + } + + // Check if user is a subscriber. + 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". + // Subscribers have full access to everything + return $body; + } + + // Check if there's an existing order for this article. + $node_id = $node ? $node->id() : NULL; + 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]; + + // 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']) && $order['paid'] === TRUE && + (!isset($order['refunded']) || $order['refunded'] === FALSE)) { + return $body; + } + + // Check order status with backend if not marked as paid. + if (!$order['paid']) { + $status = turnstile_checkOrderStatus($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; + } + } + } + + + + // No valid payment found, need to create order or show payment option. + $order_info = turnstile_create_or_get_order($node); + if ($order_info) { + return turnstile_render_preview_with_payment_button($body, $order_info); + } + + // Fallback - truncate content with safe HTML handling. + return turnstile_truncate_content_safely($body); +} + + +/** + * 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 paywall_guard_truncate_content_safely($content, $length = 200) { + // Remove HTML tags for length calculation. + $text_only = strip_tags($content); + + if (strlen($text_only) <= $length) { + return $content; + } + + // Use DOMDocument to safely truncate HTML. + $dom = new \DOMDocument(); + $dom->loadHTML('<?xml encoding="UTF-8">' . $content, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + + $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); + } + } + + return $truncated; +} + - // User hasn't paid, transform content to lowercase. - return strtolower($body); +/** + * Render content preview with payment button. + * + * @param string $body + * The original body content. + * @param array $order_info + * Order information. + * + * @return string + * Rendered preview with payment button. + */ +function paywall_guard_render_preview_with_payment_button($body, $order_info) { + // Safely truncate content to first few lines. + $preview = paywall_guard_truncate_content_safely($body, 200); + + // 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 .= '</div>'; + + return $preview . $payment_button; } + + /** * Implements hook_form_FORM_ID_alter() for node forms. */ @@ -84,6 +220,119 @@ function turnstile_form_node_form_alter(&$form, \Drupal\Core\Form\FormStateInter } } + +/** + * 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. + */ +function turnstile_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 turnstile_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; +} + + + /** * Implements hook_theme(). */ diff --git a/turnstile.permissions.yml b/turnstile.permissions.yml @@ -1,4 +1,4 @@ administer turnstile: title: 'Administer Turnstile' - description: 'Configure Turnstile settings and manage paywall-protected content.' + description: 'Configure Turnstile settings and manage payment options.' restrict access: true \ No newline at end of file