turnstile

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

commit ac82d789c27377c264a30f8e76ca894a33f7502a
parent 69a9dfb6f955b87a61679c5490963d350dce49de
Author: Christian Grothoff <christian@grothoff.org>
Date:   Sat, 11 Oct 2025 20:54:33 +0200

make transformation also work for recipes and presumably other types of nodes by rendering a teaser and adding the payment button instead of trying to transform the rendered node

Diffstat:
Atemplates/turnstile-payment-button.html.twig | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mturnstile.module | 359++++++++++++++++++++++++++++++++-----------------------------------------------
2 files changed, 225 insertions(+), 212 deletions(-)

diff --git a/templates/turnstile-payment-button.html.twig b/templates/turnstile-payment-button.html.twig @@ -0,0 +1,78 @@ +<div class="turnstile-payment-container"> + <div class="turnstile-payment-info"> + <h3>{{ 'Payment required'|t }}</h3> + <p>{{ 'Please pay to access'|t }} <strong>{{ node_title }}</strong>.</p> + </div> + + <div class="turnstile-payment-actions"> + <a href="{{ payment_url }}" class="button button--primary turnstile-pay-button" data-order-id="{{ order_id }}"> + {{ 'Pay with GNU Taler'|t }} + </a> + </div> +</div> + +<style> +.turnstile-payment-container { + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 2rem; + margin: 2rem 0; + background: #f9f9f9; +} + +.turnstile-payment-info h3 { + margin-top: 0; + color: #333; +} + +.turnstile-price { + font-size: 1.2rem; + font-weight: bold; + color: #0066cc; + margin: 1rem 0; +} + +.turnstile-payment-actions { + margin-top: 1.5rem; +} + +.turnstile-pay-button { + display: inline-block; + padding: 0.75rem 2rem; + background: #0066cc; + color: white; + text-decoration: none; + border-radius: 4px; + font-weight: bold; + transition: background 0.3s; +} + +.turnstile-pay-button:hover { + background: #0052a3; + color: white; +} + +.turnstile-access-message { + padding: 1rem; + margin: 1rem 0; + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; +} + +.turnstile-teaser-wrapper { + position: relative; + max-height: 400px; + overflow: hidden; +} + +.turnstile-teaser-wrapper::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 100px; + background: linear-gradient(to bottom, transparent, white); +} +</style> diff --git a/turnstile.module b/turnstile.module @@ -22,277 +22,203 @@ function turnstile_form_node_form_alter(&$form, \Drupal\Core\Form\FormStateInter $enabled_types = $config->get('enabled_content_types') ?: []; // Only show price field on enabled content types. - if (in_array($node->bundle(), $enabled_types)) { - 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.'); - - // Load all price categories for the description. - $price_categories = \Drupal::entityTypeManager() - ->getStorage('turnstile_price_category') - ->loadMultiple(); - - $category_list = []; - foreach ($price_categories as $category) { - $category_list[] = $category->label() . ': ' . $category->getDescription(); - } + if (! in_array($node->bundle(), $enabled_types)) { + return; + } + if (! isset($form['field_turnstile_price_category'])) { + return; + } + $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.'); - $description = t('Select a price category to enable paywall protection for this content.'); - if (!empty($category_list)) { - $description .= '<br><br><strong>' . t('Available categories:') . '</strong><ul><li>' - . implode('</li><li>', $category_list) . '</li></ul>'; - } + // Load all price categories for the description. + $price_categories = \Drupal::entityTypeManager() + ->getStorage('turnstile_price_category') + ->loadMultiple(); - $form['field_turnstile_price_category']['widget']['#description'] = $description; + $category_list = []; + foreach ($price_categories as $category) { + $category_list[] = $category->label() . ': ' . $category->getDescription(); + } - } + $description = t('Select a price category to enable paywall protection for this content.'); + if (!empty($category_list)) { + $description .= '<br><br><strong>' . t('Available categories:') . '</strong><ul><li>' + . implode('</li><li>', $category_list) . '</li></ul>'; } + + $form['field_turnstile_price_category']['widget']['#description'] = $description; } /** - * Implements hook_entity_view_alter(). Transforms the body of an entity to - * show the Turnstile dialog instead of the full body. + * Implements hook_entity_view_alter(). Transforms the body of an entity to + * show the Turnstile dialog instead of the full body if the user needs + * to pay to see the full article. */ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) { - $log_verbose = FALSE; - if (!$entity instanceof NodeInterface) { - // Turnstile only processes nodes. + // Only process nodes with turnstile enabled + if ($entity->getEntityTypeId() !== 'node') { return; } /** @var \Drupal\node\NodeInterface $node */ $node = $entity; - // Check if the node has a price field and it's not empty. - 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.'); + if (!$node->hasField('field_turnstile_price_category')) { return; } - // Get module configuration. - $config = \Drupal::config('turnstile.settings'); - $enabled_types = $config->get('enabled_content_types') ?: []; - - // 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_turnstile_price_category if turnstile is not enabled? - \Drupal::logger('turnstile')->error('Bundle has price but is not enabled with Turnstile, skipping payment.'); + /** @var TurnstilePriceCategory $price_category */ + $price_category = $node->get('field_turnstile_price_category'); + if (! $price_category) { + \Drupal::logger('turnstile')->debug('Node has no price category, skipping payment.'); return; } - // Get the original body content. - if (!$node->hasField('body')) { - if ($log_verbose) { - \Drupal::logger('turnstile')->debug('Node has no body, skipping payment.'); - } - return; - } - if ($node->get('body')->isEmpty()) { - if ($log_verbose) { - \Drupal::logger('turnstile')->debug('Node has empty body, skipping payment.'); - } - return; - } - $body_value = $node->get('body')->value; - - // Start session if not already started, we use it to persist payment status data. - if (session_status() == PHP_SESSION_NONE) { - session_start(); - } - - \Drupal::logger('turnstile')->debug('Checking for payment...'); - // 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.'); + $view_mode = $display->getMode(); + if ($view_mode !== 'full') { + \Drupal::logger('turnstile')->debug('Turnstile only active for "Full" view mode.'); return; } - // Check if there's an existing order for this article. $node_id = $node->id(); - if (!$node_id) { - // Strange, but not a case where we can do a payment. - \Drupal::logger('turnstile')->debug('No payment due to lack of node ID...'); + if (_turnstile_has_session_access($node_id)) { + \Drupal::logger('turnstile')->debug('Session has access to this node.'); return; } - if (isset($_SESSION['turnstile_orders'][$node_id])) { - $order = $_SESSION['turnstile_orders'][$node_id]; + /** @var \Drupal\turnstile\TalerMerchantApiService $api_service */ + $api_service = \Drupal::service('turnstile.api_service'); - // Check if order is paid. - if ($order['paid'] ?? FALSE) { - \Drupal::logger('turnstile')->debug('Paid order detected, passing Turnstile.'); + $order_info = _turnstile_get_node_order_info ($node_id); + if ($order_info) { + // We have an existing order, check if it was paid + $order_id = $order_info['order_id']; + $order_status = $api_service->checkOrderStatus($order_info['order_id']); + if ($order_status && $order_status['paid']) { + \Drupal::logger('turnstile')->debug('Order was paid, granting session access.'); + _turnstile_grant_session_access($node_id); return; } - - // Check order status with backend if not marked as paid. - \Drupal::logger('turnstile')->debug('Checking order status ...'); - $api_service = \Drupal::service('turnstile.api_service'); - $status = $api_service->checkOrderStatus($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; + if ($order_status && + ($order_status['order_expiration'] ?? 0) < time()) { + // If order expired, ignore it! + $order_info = NULL; } - } // end case where we had an order stored in the session + } + if (!$order_info) { + // Need to try to create a new order + $order_info = $api_service->createOrder($node); + } + if (!$order_info) { + \Drupal::logger('turnstile')->warning('Failed to setup order with Taler merchant backend!'); - // No valid payment found, need to create order or show payment option. - $order_info = turnstile_create_or_get_order($node); - if (! $order_info) { - $config = \Drupal::config('turnstile.settings'); $grant_access_on_error = $config->get('grant_access_on_error') ?: true; if ($grant_access_on_error) { - // Fallback: just return body, not good to have unhappy readers. + \Drupal::logger('turnstile')->debug('Could not setup order, disabling Turnstile.'); return; } - // Transform body into error message: - $build = []; - $build['error_message'] = [ - '#markup' => '<div class="error-message">Failed to create order with payment backend.</div>', + $pay_button = [ + '#markup' => '<div class="turnstile-error">' . t('Payment system temporarily unavailable. Please try again later.') . '</div>', ]; - return; } + else + { + _turnstile_store_order_node_mapping($node_id, $order_info); + $pay_button = [ + '#theme' => 'turnstile_payment_button', + '#order_id' => $order_info['order_id'], + '#payment_url' => $order_info['payment_url'], + '#node_title' => $node->getTitle(), + '#attached' => [ + 'library' => ['turnstile/payment_button'], + ], + ]; + } + // User needs to pay - replace full content with teaser + payment button + // Generate teaser view mode + $view_builder = \Drupal::entityTypeManager()->getViewBuilder('node'); + $teaser_build = $view_builder->view($entity, 'teaser'); + + // Replace the build array with teaser content + // Keep important metadata from original build (?) + $build = [ + '#cache' => $build['#cache'] ?? [], + '#weight' => $build['#weight'] ?? 0, + ]; - \Drupal::logger('turnstile')->debug('Rendering page with payment request.'); - - // Disable page cache, this page is going to be personalized. - \Drupal::service('page_cache_kill_switch')->trigger(); + // Add teaser content + $build['teaser'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['turnstile-teaser-wrapper']], + 'content' => $teaser_build, + '#weight' => 0, + ]; - _turnstile_transform_body_recursive ($build, $node, $order_info); + // Add payment button + $build['payment_button'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['turnstile-payment-wrapper']], + 'button' => $pay_button, + '#weight' => 10, + ]; - // 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... + // Ensure cache contexts are preserved + $build['#cache']['contexts'] = array_merge( + $build['#cache']['contexts'] ?? [], + ['user', 'url.query_args:order_id'] + ); + + // Add cache tags for the node + $build['#cache']['tags'] = array_merge( + $build['#cache']['tags'] ?? [], + $entity->getCacheTags() + ); } /** - * Recursively traverses a render array to trim full body text to summary - * and append payment option. - * - * @param array &$build - * The render array. - * @param \Drupal\Core\node\NodeInterface $node - * The node being rendered. - * @param $order - * Order data - * @param int $depth - * Recursion depth (internal use). - * @param int $max_depth - * Max recursion depth. + * Helper function to grant session access for this + * visitor to the given node ID. */ -function _turnstile_transform_body_recursive(array &$build, NodeInterface $node, array $order, $depth = 0, $max_depth = 20) { - if ($depth > $max_depth) { - return; - } - - foreach ($build as $key => &$element) { - if (is_array($element)) { - // Check if this render array appears to be for the 'body' field. - if ( - (isset($element['#field_name']) && $element['#field_name'] === 'body') || - (isset($element['#type']) && $element['#type'] === 'processed_text' && isset($element['#text'])) || - $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; - - $replacement_text = $summary ?: text_summary($full, $format, 300); - - // Replace with processed summary or trimmed text. - $element = [ - '#type' => 'processed_text', - '#text' => $replacement_text . turnstile_render_payment_button ($order), - '#format' => $format, - '#cache' => ['contexts' => ['url.path']], // Optional caching metadata - ]; - - // FIXME: Here we probably need to attach - // some JS library to make long-polling for - // the order status work in the future. - // Something like: - // $build['#attached']['library'][] = 'turnstile/paymentcheck_js'; - // might work. - } - } - else { - // Recurse into the element. - _turnstile_transform_body_recursive($element, $node, $order, $depth + 1, $max_depth); - } - } - } +function _turnstile_grant_session_access($node_id) { + $session = \Drupal::request()->getSession(); + $access_data = $session->get('turnstile_access', []); + $access_data[$node_id] = TRUE; + $session->set('turnstile_access', $access_data); } /** - * Render payment button. - * - * @param array $order_info - * Order information. - * - * @return string - * Rendered payment button. + * Helper function to check session access. Checks if this + * visitor has been granted access to the given $node_id. */ -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; +function _turnstile_has_session_access($node_id) { + $session = \Drupal::request()->getSession(); + $access_data = $session->get('turnstile_access', []); + return $access_data[$node_id] ?? FALSE; } /** - * 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. + * Store the mapping between order_id and node_id. + * Uses session to track which orders belong to which nodes. */ -function turnstile_create_or_get_order(NodeInterface $node) { - $node_id = $node->id(); +function _turnstile_store_order_node_mapping($node_id, $order_info) { + $session = \Drupal::request()->getSession(); + $node_orders = $session->get('turnstile_node_orders', []); + $node_orders[$node_id] = $order_info; + $session->set('turnstile_node_orders', $node_orders); +} - // Check if we already have an order for this node in session - if (isset($_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 - $api_service = \Drupal::service('turnstile.api_service'); - $order_info = $api_service->createOrder($node); - if (! $order_info) { - return FALSE; - } - // Store order in session - $_SESSION['turnstile_orders'][$node_id] = $order_info; - \Drupal::logger('turnstile')->debug('Created new order.'); - return $order_info; +/** + * Get the order_info associated with a node_id. + */ +function _turnstile_get_node_order_info($node_id) { + $session = \Drupal::request()->getSession(); + $node_orders = $session->get('turnstile_node_orders', []); + return $node_orders[$node_id] ?? NULL; } @@ -301,6 +227,15 @@ function turnstile_create_or_get_order(NodeInterface $node) { */ function turnstile_theme() { return [ + 'turnstile_payment_button' => [ + 'variables' => [ + 'order_id' => NULL, + 'payment_url' => NULL, + 'node_title' => NULL, + 'price' => NULL, + ], + 'template' => 'turnstile-payment-button', + ], 'turnstile_settings' => [ 'variables' => [ 'config' => NULL,