turnstile

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

taler_turnstile.module (10283B)


      1 <?php
      2 
      3 /**
      4  * @file
      5  * Main module file for Turnstile.
      6  *
      7  * The paywall flow is built around a static, cacheable paywall page
      8  * that references a "paivana"-style template in the merchant backend.
      9  * No PHP session is touched on the unprotected path: the gate is a
     10  * cookie minted by /taler-turnstile/paivana once the visitor's wallet
     11  * has paid.
     12  */
     13 
     14 use Drupal\Core\Cache\Cache;
     15 use Drupal\Core\Entity\EntityInterface;
     16 use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
     17 
     18 
     19 /**
     20  * Cookie name used to grant access to a previously-paid fulfillment URL.
     21  * Computed and verified by Drupal\taler_turnstile\PaivanaCookie.
     22  */
     23 const TALER_TURNSTILE_COOKIE = 'taler_turnstile_paivana';
     24 
     25 
     26 /**
     27  * Implements hook_form_FORM_ID_alter() for node forms.  Adds a
     28  * description for the Turnstile price category field.
     29  */
     30 function taler_turnstile_form_node_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
     31   $node = $form_state->getFormObject()->getEntity();
     32   $config = \Drupal::config('taler_turnstile.settings');
     33   $enabled_types = $config->get('enabled_content_types') ?: [];
     34 
     35   // Only show price field on enabled content types.
     36   if (! in_array($node->bundle(), $enabled_types)) {
     37     return;
     38   }
     39   if (! isset($form['field_taler_turnstile_prcat'])) {
     40     return;
     41   }
     42   $form['field_taler_turnstile_prcat']['#group'] = 'meta';
     43 
     44   // Load all price categories for the description.
     45   $price_categories = \Drupal::entityTypeManager()
     46     ->getStorage('taler_turnstile_price_category')
     47     ->loadMultiple();
     48 
     49   $category_list = [];
     50   foreach ($price_categories as $category) {
     51     $category_list[] = $category->label() . ': ' . $category->getDescription();
     52   }
     53 
     54   $description = t('Select a price category to enable paywall protection for this content.');
     55   if (!empty($category_list)) {
     56     $description .= '<br><br><strong>' . t('Available categories:') . '</strong><ul><li>'
     57       . implode('</li><li>', $category_list) . '</li></ul>';
     58   }
     59 
     60   $form['field_taler_turnstile_prcat']['widget']['#description'] = $description;
     61 }
     62 
     63 
     64 /**
     65  * Implements hook_entity_view_alter().
     66  *
     67  * For nodes that carry a price category, swap the full-mode build for
     68  * a teaser + paywall button unless the visitor has already proven
     69  * payment (Paivana cookie) or holds an applicable subscription.
     70  */
     71 function taler_turnstile_entity_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
     72   if ($entity->getEntityTypeId() !== 'node') {
     73     return;
     74   }
     75   /** @var \Drupal\node\NodeInterface $node */
     76   $node = $entity;
     77 
     78   if (!$node->hasField('field_taler_turnstile_prcat')) {
     79     return;
     80   }
     81   /** @var \Drupal\Core\Field\EntityReferenceFieldItemList $field */
     82   $field = $node->get('field_taler_turnstile_prcat');
     83   if ($field->isEmpty()) {
     84     return;
     85   }
     86   /** @var \Drupal\taler_turnstile\Entity\TurnstilePriceCategory $price_category */
     87   $price_category = $field->entity;
     88   if (! $price_category) {
     89     return;
     90   }
     91   if ($display->getMode() !== 'full') {
     92     return;
     93   }
     94 
     95   $request = \Drupal::request();
     96   $fulfillment_url = $node->toUrl('canonical', ['absolute' => TRUE])->toString();
     97 
     98   // Subscriber check. Only consult the session if one already exists,
     99   // so unauthenticated bot traffic never causes a session start (which
    100   // would defeat page caching).
    101   if ($request->hasPreviousSession()) {
    102     $subscriptions = $price_category->getFullSubscriptions();
    103     foreach ($subscriptions as $subscription_id) {
    104       if (_taler_turnstile_is_subscriber($subscription_id)) {
    105         \Drupal::logger('taler_turnstile')->debug('Subscriber detected, granting access.');
    106         // Do not advertise this response as cacheable: it depends on
    107         // session state.
    108         $build['#cache']['max-age'] = 0;
    109         return;
    110       }
    111     }
    112   }
    113 
    114   // Paywall already cleared by a prior payment? Trust the cookie.
    115   /** @var \Drupal\taler_turnstile\PaivanaCookie $cookies */
    116   $cookies = \Drupal::service('taler_turnstile.cookie');
    117   if ($cookies->verify($request, $fulfillment_url)) {
    118     \Drupal::logger('taler_turnstile')->debug('Valid Paivana cookie, granting access to @url', ['@url' => $fulfillment_url]);
    119     // The cookies cache context lets Drupal's page caches key on
    120     // the access cookie and emit a properly scoped Vary header,
    121     // so visitors without the cookie still see the paywall version.
    122     // Marking the response 'private' keeps shared HTTP caches from
    123     // handing one paying visitor's full-content response to another
    124     // visitor whose request happens to lack the cookie.
    125     $build['#cache']['contexts'][] = 'cookies:' . TALER_TURNSTILE_COOKIE;
    126     $build['#attached']['http_header'][] = ['Cache-Control', 'private', TRUE];
    127     return;
    128   }
    129 
    130   // Not subscribed, no cookie -> render the paywall.
    131   $config = \Drupal::config('taler_turnstile.settings');
    132   $backend_url = $config->get('payment_backend_url');
    133   if (empty($backend_url)) {
    134     \Drupal::logger('taler_turnstile')->warning('No backend URL configured, cannot present paywall.');
    135     if ($config->get('grant_access_on_error') ?? TRUE) {
    136       return;
    137     }
    138     $build = [
    139       '#markup' => '<div class="taler-turnstile-error">' . t('Payment system temporarily unavailable. Please try again later.') . '</div>',
    140     ];
    141     return;
    142   }
    143 
    144   $template_id = $price_category->getTemplateId();
    145   // Maximum lifetime of the eventually-issued cookie, in seconds.
    146   // Mirrors what we publish on the merchant template.
    147   $max_pickup_delay = \Drupal\taler_turnstile\TalerMerchantApiService::ORDER_VALIDITY_SECONDS;
    148 
    149   // Pre-compute the taler:// pay-template URI so we can both
    150   // advertise it as the Paivana header (for non-JS Taler clients)
    151   // and let the JS render its QR code from the same string.
    152   $proto_suffix = (parse_url($backend_url, PHP_URL_SCHEME) === 'http') ? '+http' : '';
    153   $host = parse_url($backend_url, PHP_URL_HOST);
    154   $port = parse_url($backend_url, PHP_URL_PORT);
    155   $path = rtrim(parse_url($backend_url, PHP_URL_PATH) ?? '/', '/');
    156   $authority = $host . ($port ? ':' . $port : '');
    157   $taler_uri = 'taler' . $proto_suffix . '://pay-template/' . $authority . $path . '/' . rawurlencode($template_id);
    158 
    159   $pay_button = [
    160     '#theme' => 'taler_turnstile_payment_button',
    161     '#merchant_backend' => $backend_url,
    162     '#template_id' => $template_id,
    163     '#max_pickup_delay' => $max_pickup_delay,
    164     '#node_title' => $node->getTitle(),
    165     '#price_hint' => $price_category->getPriceHint(),
    166     '#subscription_hint' => $price_category->getSubscriptionHint(),
    167     '#fulfillment_url' => $fulfillment_url,
    168     // Built from the raw path (not Url::fromRoute) so the URL never
    169     // carries a language prefix — the endpoint is language-agnostic
    170     // and a /en/ in front just confuses operators reading logs.
    171     '#confirm_url' => \Drupal\Core\Url::fromUserInput('/taler-turnstile/paivana')->toString(),
    172     '#paivana_uri' => $taler_uri,
    173     '#attached' => [
    174       'library' => ['taler_turnstile/payment_button'],
    175       // Surface the Paivana URI as an HTTP header for non-JS clients.
    176       // The cookies cache context attached below already makes Drupal
    177       // emit a properly scoped Vary header for downstream caches.
    178       'http_header' => [
    179         ['Paivana', $taler_uri, FALSE],
    180       ],
    181     ],
    182   ];
    183 
    184   $view_builder = \Drupal::entityTypeManager()->getViewBuilder('node');
    185   $teaser_build = $view_builder->view($entity, 'teaser');
    186 
    187   // Strip the rendered field children but keep all the entity render
    188   // metadata ('#entity_type', '#node', '#view_mode', '#theme',
    189   // '#pre_render', ...). Downstream rendering — in particular the
    190   // _title_callback in EntityViewController::buildTitle() — relies on
    191   // '#entity_type', so blowing $build away with a fresh array crashes
    192   // the page with "Undefined array key '#entity_type'".
    193   foreach (array_keys($build) as $key) {
    194     if ($key === '' || $key[0] !== '#') {
    195       unset($build[$key]);
    196     }
    197   }
    198 
    199   // Page is identical for everyone *without* the cookie, so it
    200   // remains cacheable. The cookie context only kicks in once the
    201   // visitor has paid.
    202   $build['#cache']['contexts'] = array_unique(array_merge(
    203     $build['#cache']['contexts'] ?? [],
    204     ['cookies:' . TALER_TURNSTILE_COOKIE]
    205   ));
    206   $build['#cache']['tags'] = Cache::mergeTags(
    207     $build['#cache']['tags'] ?? [],
    208     $price_category->getCacheTags()
    209   );
    210 
    211   $build['teaser'] = [
    212     '#type' => 'container',
    213     '#attributes' => ['class' => ['taler-turnstile-teaser-wrapper']],
    214     'content' => $teaser_build,
    215     '#weight' => 0,
    216   ];
    217   $build['payment_button'] = [
    218     '#type' => 'container',
    219     '#attributes' => ['class' => ['taler-turnstile-payment-wrapper']],
    220     'button' => $pay_button,
    221     '#weight' => 10,
    222   ];
    223 
    224 }
    225 
    226 
    227 /**
    228  * Returns TRUE if the visitor's session has a non-expired entry for
    229  * the given subscription slug. Does NOT start a session if none yet
    230  * exists.
    231  */
    232 function _taler_turnstile_is_subscriber($subscription_slug) {
    233   $request = \Drupal::request();
    234   if (! $request->hasPreviousSession()) {
    235     return FALSE;
    236   }
    237   $session = $request->getSession();
    238   $access_data = $session->get('taler_turnstile_subscriptions', []);
    239   return ($access_data[$subscription_slug] ?? 0) >= time();
    240 }
    241 
    242 
    243 /**
    244  * Helper used by the Paivana confirmation controller to record that
    245  * a paid contract bought a subscription valid until $expiration.
    246  */
    247 function _taler_turnstile_grant_subscriber_access($subscription_slug, $expiration) {
    248   $session = \Drupal::request()->getSession();
    249   $access_data = $session->get('taler_turnstile_subscriptions', []);
    250   $access_data[$subscription_slug] = $expiration;
    251   $session->set('taler_turnstile_subscriptions', $access_data);
    252 }
    253 
    254 
    255 /**
    256  * Implements hook_theme().
    257  */
    258 function taler_turnstile_theme() {
    259   return [
    260     'taler_turnstile_payment_button' => [
    261       'variables' => [
    262         'merchant_backend' => NULL,
    263         'template_id' => NULL,
    264         'max_pickup_delay' => 86400,
    265         'node_title' => NULL,
    266         'price_hint' => NULL,
    267         'subscription_hint' => NULL,
    268         'fulfillment_url' => NULL,
    269         'confirm_url' => NULL,
    270         'paivana_uri' => NULL,
    271       ],
    272       'template' => 'taler-turnstile-payment-button',
    273     ],
    274     'taler_turnstile_settings' => [
    275       'variables' => [
    276         'config' => NULL,
    277       ],
    278     ],
    279   ];
    280 }