commit 8b3a41b9ba801823fa3909605725c2afd0dbcc14
parent 06317974c88dd30b2034dc641864fadd491406c3
Author: Christian Grothoff <christian@grothoff.org>
Date: Thu, 24 Jul 2025 01:13:11 +0200
refactor...
Diffstat:
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