commit a06c174c518b0561e44fc3c5001746bae9293c86
Author: Christian Grothoff <christian@grothoff.org>
Date: Wed, 23 Jul 2025 23:53:54 +0200
initial sketch
Diffstat:
12 files changed, 870 insertions(+), 0 deletions(-)
diff --git a/README.md b/README.md
@@ -0,0 +1,77 @@
+# Turnstile
+
+A Drupal module that adds paywall functionality to nodes with configurable content transformation.
+
+## Features
+
+- Adds a "price" field to configurable content types
+- Implements paywall protection with cookie-based access control
+- Transforms content for unpaid users (currently converts to lowercase)
+- Configurable content types for paywall protection
+- Integration with external payment backend
+- Admin interface for configuration
+
+## Installation
+
+1. Download and extract the module to your `modules/custom/` directory
+2. Enable the module via Drush: `drush en turnstile`
+3. Or enable via the Drupal admin interface at `/admin/modules`
+
+## Configuration
+
+Navigate to `/admin/config/content/Turnstile` to configure:
+
+- **Enabled Content Types**: Select which content types should have the price field and paywall protection
+- **Payment Backend URL**: HTTP URL for your payment backend service
+- **Access Token**: Authentication token for the payment backend
+
+## Usage
+
+1. Create or edit a node of an enabled content type
+2. Set a price in the "Price" field to enable paywall protection
+3. Users without the `payment_cookie` will see transformed content (lowercase)
+4. Users with the cookie will see the original content
+
+## How it Works
+
+The module uses `hook_entity_view_alter()` to intercept node rendering and calls the `turnstile_check_paywall_cookie()` function to:
+
+1. Check if a `payment_cookie` is set
+2. If not set, transform the content body to lowercase
+3. If set, display the original content
+
+## Extending the Module
+
+You can modify the `turnstile_check_paywall_cookie()` function in `turnstile.module` to implement different transformation logic or integrate with your payment system.
+
+The `Turnstile` class provides methods for backend payment verification and can be extended for more complex payment workflows.
+
+## File Structure
+
+```
+turnstile/
+├── config/
+│ └── install/
+│ └── turnstile.settings.yml
+├── src/
+│ ├── Form/
+│ │ └── TurnstileSettingsForm.php
+│ └── Service/
+│ └── Turnstile.php
+├── turnstile.info.yml
+├── turnstile.install
+├── turnstile.module
+├── turnstile.permissions.yml
+├── turnstile.routing.yml
+├── turnstile.services.yml
+└── README.md
+```
+
+## Requirements
+
+- Drupal 9 or 10
+- PHP 7.4 or higher
+
+## License
+
+This module is provided as-is for demonstration purposes.
+\ No newline at end of file
diff --git a/config/install/turnstile.settings.yml b/config/install/turnstile.settings.yml
@@ -0,0 +1,4 @@
+enabled_content_types:
+ - article
+payment_backend_url: ''
+access_token: ''
+\ No newline at end of file
diff --git a/config/schema/turnstile.schema.yml b/config/schema/turnstile.schema.yml
@@ -0,0 +1,16 @@
+turnstile.settings:
+ type: config_object
+ label: 'Paywall Guard settings'
+ mapping:
+ enabled_content_types:
+ type: sequence
+ label: 'Enabled content types'
+ sequence:
+ type: string
+ label: 'Content type'
+ payment_backend_url:
+ type: string
+ label: 'Payment backend URL'
+ access_token:
+ type: string
+ label: 'Access token'
diff --git a/src/Form/TurnstileSettingsForm.php b/src/Form/TurnstileSettingsForm.php
@@ -0,0 +1,312 @@
+<?php
+
+namespace Drupal\turnstile\Form;
+
+use Drupal\Core\Form\ConfigFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\Core\Entity\Entity\EntityFormDisplay;
+use Drupal\Core\Entity\Entity\EntityViewDisplay;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Configure Turnstile settings.
+ */
+class TurnstileSettingsForm extends ConfigFormBase {
+
+ /**
+ * The entity type manager.
+ *
+ * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+ */
+ protected $entityTypeManager;
+
+ /**
+ * Constructs a TurnstileSettingsForm object.
+ *
+ * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+ * The factory for configuration objects.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager.
+ */
+ public function __construct(ConfigFactoryInterface $config_factory, EntityTypeManagerInterface $entity_type_manager) {
+ parent::__construct($config_factory);
+ $this->entityTypeManager = $entity_type_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('config.factory'),
+ $container->get('entity_type.manager')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEditableConfigNames() {
+ return ['turnstile.settings'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'turnstile_settings_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $config = $this->config('turnstile.settings');
+
+ // Get all available content types.
+ $content_types = $this->entityTypeManager->getStorage('node_type')->loadMultiple();
+ $type_options = [];
+ foreach ($content_types as $type) {
+ $type_options[$type->id()] = $type->label();
+ }
+
+ $form['enabled_content_types'] = [
+ '#type' => 'checkboxes',
+ '#title' => $this->t('Enabled Content Types'),
+ '#description' => $this->t('Select which content types should have the price field and be subject to paywall transformation.'),
+ '#options' => $type_options,
+ '#default_value' => $config->get('enabled_content_types') ?: [],
+ ];
+
+ $form['payment_backend_url'] = [
+ '#type' => 'url',
+ '#title' => $this->t('Payment Backend URL'),
+ '#description' => $this->t('HTTP URL for the payment backend service.'),
+ '#default_value' => $config->get('payment_backend_url') ?: '',
+ '#maxlength' => 255,
+ ];
+
+ $form['access_token'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('Access Token'),
+ '#description' => $this->t('Access token for authenticating with the payment backend.'),
+ '#default_value' => $config->get('access_token') ?: '',
+ '#maxlength' => 255,
+ ];
+
+ return parent::buildForm($form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $config = $this->config('turnstile.settings');
+
+ // Get old and new content types.
+ $old_enabled_types = $config->get('enabled_content_types') ?: [];
+ $new_enabled_types = array_filter($form_state->getValue('enabled_content_types'));
+ $new_enabled_types = array_values($new_enabled_types);
+
+ // Find content types to add and remove.
+ $types_to_add = array_diff($new_enabled_types, $old_enabled_types);
+ $types_to_remove = array_diff($old_enabled_types, $new_enabled_types);
+
+ // Add fields to newly enabled content types.
+ if (!empty($types_to_add)) {
+ $this->addFieldsToContentTypes($types_to_add);
+ }
+
+ // Remove fields from disabled content types.
+ if (!empty($types_to_remove)) {
+ $this->removeFieldsFromContentTypes($types_to_remove);
+ }
+
+ // Save configuration.
+ $config->set('enabled_content_types', $new_enabled_types);
+ $config->set('payment_backend_url', $form_state->getValue('payment_backend_url'));
+ $config->set('access_token', $form_state->getValue('access_token'));
+ $config->save();
+
+ parent::submitForm($form, $form_state);
+
+ // Display summary of changes.
+ if (!empty($types_to_add) || !empty($types_to_remove)) {
+ $this->displayFieldChanges($types_to_add, $types_to_remove);
+ }
+ }
+
+ /**
+ * Add price fields to content types.
+ *
+ * @param array $bundles
+ * Array of content type machine names.
+ */
+ protected function addFieldsToContentTypes(array $bundles) {
+ // Ensure field storage exists.
+ $field_storage = FieldStorageConfig::loadByName('node', 'field_price');
+ if (!$field_storage) {
+ $field_storage = FieldStorageConfig::create([
+ 'field_name' => 'field_price',
+ 'entity_type' => 'node',
+ 'type' => 'string',
+ 'cardinality' => 1,
+ 'settings' => [
+ 'max_length' => 255,
+ ],
+ ]);
+ $field_storage->save();
+ }
+
+ foreach ($bundles as $bundle) {
+ // Verify content type exists.
+ if (!$this->entityTypeManager->getStorage('node_type')->load($bundle)) {
+ continue;
+ }
+
+ // Check if field already exists for this bundle.
+ $existing_field = FieldConfig::loadByName('node', $bundle, 'field_price');
+ if ($existing_field) {
+ continue;
+ }
+
+ // Create field configuration.
+ $field_config = FieldConfig::create([
+ 'field_storage' => $field_storage,
+ 'bundle' => $bundle,
+ 'label' => 'Price',
+ 'description' => 'Price for accessing this content',
+ 'required' => FALSE,
+ ]);
+ $field_config->save();
+
+ // Add to form display.
+ $form_display = EntityFormDisplay::load('node.' . $bundle . '.default');
+ if ($form_display) {
+ $form_display->setComponent('field_price', [
+ 'type' => 'string_textfield',
+ 'weight' => 10,
+ 'settings' => [
+ 'size' => 60,
+ 'placeholder' => '',
+ ],
+ ]);
+ $form_display->save();
+ }
+
+ // Add to view display.
+ $view_display = EntityViewDisplay::load('node.' . $bundle . '.default');
+ if ($view_display) {
+ $view_display->setComponent('field_price', [
+ 'type' => 'string',
+ 'weight' => 10,
+ 'label' => 'above',
+ ]);
+ $view_display->save();
+ }
+ }
+ }
+
+ /**
+ * Remove price fields from content types.
+ *
+ * @param array $bundles
+ * Array of content type machine names.
+ */
+ protected function removeFieldsFromContentTypes(array $bundles) {
+ foreach ($bundles as $bundle) {
+ $field_config = FieldConfig::loadByName('node', $bundle, 'field_price');
+ if ($field_config) {
+ $field_config->delete();
+ }
+
+ // Remove from form display.
+ $form_display = EntityFormDisplay::load('node.' . $bundle . '.default');
+ if ($form_display) {
+ $form_display->removeComponent('field_price');
+ $form_display->save();
+ }
+
+ // Remove from view display.
+ $view_display = EntityViewDisplay::load('node.' . $bundle . '.default');
+ if ($view_display) {
+ $view_display->removeComponent('field_price');
+ $view_display->save();
+ }
+ }
+
+ // Check if field storage should be deleted.
+ $this->cleanupFieldStorage();
+ }
+
+ /**
+ * Clean up field storage if no content types are using it.
+ */
+ protected function cleanupFieldStorage() {
+ $field_storage = FieldStorageConfig::loadByName('node', 'field_price');
+ if (!$field_storage) {
+ return;
+ }
+
+ // Get all field configs that use this storage.
+ $field_configs = $this->entityTypeManager
+ ->getStorage('field_config')
+ ->loadByProperties([
+ 'field_storage' => $field_storage,
+ ]);
+
+ // If no field configs exist, delete the storage.
+ if (empty($field_configs)) {
+ $field_storage->delete();
+ }
+ }
+
+ /**
+ * Display messages about field changes.
+ *
+ * @param array $types_added
+ * Content types that had fields added.
+ * @param array $types_removed
+ * Content types that had fields removed.
+ */
+ protected function displayFieldChanges(array $types_added, array $types_removed) {
+ $content_types = $this->entityTypeManager->getStorage('node_type')->loadMultiple();
+
+ if (!empty($types_added)) {
+ $added_labels = [];
+ foreach ($types_added as $type) {
+ if (isset($content_types[$type])) {
+ $added_labels[] = $content_types[$type]->label();
+ }
+ }
+ if (!empty($added_labels)) {
+ $this->messenger()->addStatus(
+ $this->t('Price field added to: @types', [
+ '@types' => implode(', ', $added_labels),
+ ])
+ );
+ }
+ }
+
+ if (!empty($types_removed)) {
+ $removed_labels = [];
+ foreach ($types_removed as $type) {
+ if (isset($content_types[$type])) {
+ $removed_labels[] = $content_types[$type]->label();
+ }
+ }
+ if (!empty($removed_labels)) {
+ $this->messenger()->addStatus(
+ $this->t('Price field removed from: @types', [
+ '@types' => implode(', ', $removed_labels),
+ ])
+ );
+ }
+ }
+ }
+
+}
+\ No newline at end of file
diff --git a/src/Service/TurnstileService.php b/src/Service/TurnstileService.php
@@ -0,0 +1,241 @@
+<?php
+
+namespace Drupal\turnstile\Service;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\RequestException;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Service for handling paywall functionality.
+ */
+class Turnstile {
+
+ /**
+ * The configuration factory.
+ *
+ * @var \Drupal\Core\Config\ConfigFactoryInterface
+ */
+ protected $configFactory;
+
+ /**
+ * The HTTP client.
+ *
+ * @var \GuzzleHttp\ClientInterface
+ */
+ protected $httpClient;
+
+ /**
+ * Constructs a Turnstile object.
+ *
+ * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+ * The configuration factory.
+ * @param \GuzzleHttp\ClientInterface $http_client
+ * The HTTP client.
+ */
+ public function __construct(ConfigFactoryInterface $config_factory, ClientInterface $http_client) {
+ $this->configFactory = $config_factory;
+ $this->httpClient = $http_client;
+ }
+
+ /**
+ * Verify payment status with the backend.
+ *
+ * @param string $node_id
+ * The node ID.
+ * @param string $user_id
+ * The user ID.
+ *
+ * @return bool
+ * TRUE if payment is verified, FALSE otherwise.
+ */
+ public function verifyPayment($node_id, $user_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->post($backend_url . '/verify', [
+ 'json' => [
+ 'node_id' => $node_id,
+ 'user_id' => $user_id,
+ ],
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $access_token,
+ 'Content-Type' => 'application/json',
+ ],
+ ]);
+
+ $data = json_decode($response->getBody(), TRUE);
+ return isset($data['verified']) && $data['verified'] === TRUE;
+ }
+ catch (RequestException $e) {
+ // Log the error and return FALSE.
+ \Drupal::logger('turnstile')->error('Payment verification failed: @message', ['@message' => $e->getMessage()]);
+ return FALSE;
+ }
+ }
+
+ /**
+ * Check if user has access to content.
+ *
+ * @param string $node_id
+ * The node ID.
+ * @param string $user_id
+ * The user ID.
+ *
+ * @return bool
+ * TRUE if user has access, FALSE otherwise.
+ */
+ public function hasAccess($node_id, $user_id) {
+ // Start session if not already started.
+ if (session_status() == PHP_SESSION_NONE) {
+ session_start();
+ }
+
+ // Check if user is a subscriber.
+ if (isset($_SESSION['paywall_subscriber']) && $_SESSION['paywall_subscriber'] === TRUE) {
+ return TRUE;
+ }
+
+ // Check session-based orders.
+ if (isset($_SESSION['paywall_orders'][$node_id])) {
+ $order = $_SESSION['paywall_orders'][$node_id];
+
+ // Check if order is paid and not refunded.
+ 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.
+ $_SESSION['paywall_orders'][$node_id]['paid'] = TRUE;
+ return TRUE;
+ }
+ }
+ }
+
+ // Fallback to backend verification.
+ return $this->verifyPayment($node_id, $user_id);
+ }
+
+ /**
+ * 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,
+ ],
+ ]);
+
+ $result = json_decode($response->getBody(), TRUE);
+
+ 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,
+ 'products' => [
+ [
+ 'product_id' => 'node_' . $node->id(),
+ 'description' => 'Access to article: ' . $node->getTitle(),
+ 'quantity' => 1,
+ 'price' => $price,
+ ]
+ ],
+ ],
+ 'create_token' => TRUE,
+ ];
+
+ try {
+ $response = $this->httpClient->post($backend_url . '/private/orders', [
+ 'json' => $order_data,
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $access_token,
+ 'Content-Type' => 'application/json',
+ ],
+ ]);
+
+ $result = json_decode($response->getBody(), TRUE);
+
+ if (isset($result['order_id'])) {
+ return [
+ 'order_id' => $result['order_id'],
+ 'order_token' => $result['token'] ?? '',
+ '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.info.yml b/turnstile.info.yml
@@ -0,0 +1,10 @@
+name: Turnstile
+type: module
+description: 'Adds price field to nodes and implements paywall functionality with configurable content transformation.'
+core_version_requirement: ^9 || ^10
+package: System
+dependencies:
+ - drupal:node
+ - drupal:field
+ - drupal:user
+configure: turnstile.settings
diff --git a/turnstile.install b/turnstile.install
@@ -0,0 +1,81 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the Turnstile module.
+ */
+
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\Core\Entity\Entity\EntityFormDisplay;
+use Drupal\Core\Entity\Entity\EntityViewDisplay;
+
+/**
+ * Implements hook_install().
+ */
+function turnstile_install() {
+ // Create the price field storage.
+ $field_storage = FieldStorageConfig::create([
+ 'field_name' => 'field_price',
+ 'entity_type' => 'node',
+ 'type' => 'string',
+ 'cardinality' => 1,
+ 'settings' => [
+ 'max_length' => 255,
+ ],
+ ]);
+ $field_storage->save();
+
+ // Get enabled content types from config or default to 'article'.
+ $config = \Drupal::config('turnstile.settings');
+ $enabled_types = $config->get('enabled_content_types') ?: ['article'];
+
+ // Add the price field to enabled content types.
+ foreach ($enabled_types as $bundle) {
+ if (\Drupal::entityTypeManager()->getStorage('node_type')->load($bundle)) {
+ $field_config = FieldConfig::create([
+ 'field_storage' => $field_storage,
+ 'bundle' => $bundle,
+ 'label' => 'Price',
+ 'description' => 'Price for accessing this content',
+ 'required' => FALSE,
+ ]);
+ $field_config->save();
+
+ // Add to form display.
+ $form_display = EntityFormDisplay::load('node.' . $bundle . '.default');
+ if ($form_display) {
+ $form_display->setComponent('field_price', [
+ 'type' => 'string_textfield',
+ 'weight' => 10,
+ ]);
+ $form_display->save();
+ }
+
+ // Add to view display.
+ $view_display = EntityViewDisplay::load('node.' . $bundle . '.default');
+ if ($view_display) {
+ $view_display->setComponent('field_price', [
+ 'type' => 'string',
+ 'weight' => 10,
+ 'label' => 'above',
+ ]);
+ $view_display->save();
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function turnstile_uninstall() {
+ // Remove field configurations.
+ $field_storage = FieldStorageConfig::loadByName('node', 'field_price');
+ if ($field_storage) {
+ $field_storage->delete();
+ }
+
+ // Clean up configuration.
+ \Drupal::configFactory()->getEditable('turnstile.settings')->delete();
+}
+\ No newline at end of file
diff --git a/turnstile.links.menu.yml b/turnstile.links.menu.yml
@@ -0,0 +1,6 @@
+turnstile.settings:
+ title: 'Turnstile'
+ description: 'Configure Turnstile settings'
+ parent: system.admin_config_system
+ route_name: turnstile.settings
+ weight: 99
diff --git a/turnstile.module b/turnstile.module
@@ -0,0 +1,98 @@
+<?php
+
+/**
+ * @file
+ * Main module file for Turnstile.
+ */
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
+use Drupal\node\NodeInterface;
+
+/**
+ * Implements hook_entity_view_alter().
+ */
+function turnstile_entity_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
+ // Only process nodes.
+ if (!$entity instanceof NodeInterface) {
+ 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($entity->bundle(), $enabled_types)) {
+ 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 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);
+
+ // 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.
+ *
+ * @param string $body
+ * The original body content.
+ *
+ * @return string
+ * 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.
+ return $body;
+ }
+
+ // User hasn't paid, transform content to lowercase.
+ return strtolower($body);
+}
+
+
+/**
+ * Implements hook_form_FORM_ID_alter() for node forms.
+ */
+function turnstile_form_node_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
+ $node = $form_state->getFormObject()->getEntity();
+ $config = \Drupal::config('turnstile.settings');
+ $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_price'])) {
+ $form['field_price']['#group'] = 'meta';
+ $form['field_price']['widget'][0]['value']['#description'] = t('Set a price to enable paywall protection for this content.');
+ }
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function turnstile_theme() {
+ return [
+ 'turnstile_settings' => [
+ 'variables' => [
+ 'config' => NULL,
+ ],
+ ],
+ ];
+}
+\ No newline at end of file
diff --git a/turnstile.permissions.yml b/turnstile.permissions.yml
@@ -0,0 +1,4 @@
+administer turnstile:
+ title: 'Administer Turnstile'
+ description: 'Configure Turnstile settings and manage paywall-protected content.'
+ restrict access: true
+\ No newline at end of file
diff --git a/turnstile.routing.yml b/turnstile.routing.yml
@@ -0,0 +1,9 @@
+turnstile.settings:
+ path: '/admin/config/system/Turnstile'
+ defaults:
+ _form: '\Drupal\turnstile\Form\TurnstileSettingsForm'
+ _title: 'Turnstile Settings'
+ requirements:
+ _permission: 'administer Turnstile'
+ options:
+ _admin_route: TRUE
+\ No newline at end of file
diff --git a/turnstile.services.yml b/turnstile.services.yml
@@ -0,0 +1,4 @@
+services:
+ turnstile.turnstile:
+ class: Drupal\turnstile\Service\Turnstile
+ arguments: ['@config.factory', '@http_client']