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:
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,