turnstile

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

taler_turnstile.module (9288B)


      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   $form['field_taler_turnstile_prcat']['widget'][0]['value']['#description'] = t('Set a price category to enable paywall protection for this content.');
     44 
     45   // Load all price categories for the description.
     46   $price_categories = \Drupal::entityTypeManager()
     47     ->getStorage('taler_turnstile_price_category')
     48     ->loadMultiple();
     49 
     50   $category_list = [];
     51   foreach ($price_categories as $category) {
     52     $category_list[] = $category->label() . ': ' . $category->getDescription();
     53   }
     54 
     55   $description = t('Select a price category to enable paywall protection for this content.');
     56   if (!empty($category_list)) {
     57     $description .= '<br><br><strong>' . t('Available categories:') . '</strong><ul><li>'
     58       . implode('</li><li>', $category_list) . '</li></ul>';
     59   }
     60 
     61   $form['field_taler_turnstile_prcat']['widget']['#description'] = $description;
     62 }
     63 
     64 
     65 /**
     66  * Implements hook_entity_view_alter().
     67  *
     68  * For nodes that carry a price category, swap the full-mode build for
     69  * a teaser + paywall button unless the visitor has already proven
     70  * payment (Paivana cookie) or holds an applicable subscription.
     71  */
     72 function taler_turnstile_entity_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
     73   if ($entity->getEntityTypeId() !== 'node') {
     74     return;
     75   }
     76   /** @var \Drupal\node\NodeInterface $node */
     77   $node = $entity;
     78 
     79   if (!$node->hasField('field_taler_turnstile_prcat')) {
     80     return;
     81   }
     82   /** @var \Drupal\Core\Field\EntityReferenceFieldItemList $field */
     83   $field = $node->get('field_taler_turnstile_prcat');
     84   if ($field->isEmpty()) {
     85     return;
     86   }
     87   /** @var \Drupal\taler_turnstile\Entity\TurnstilePriceCategory $price_category */
     88   $price_category = $field->entity;
     89   if (! $price_category) {
     90     return;
     91   }
     92   if ($display->getMode() !== 'full') {
     93     return;
     94   }
     95 
     96   $request = \Drupal::request();
     97   $fulfillment_url = $node->toUrl('canonical', ['absolute' => TRUE])->toString();
     98 
     99   // Subscriber check. Only consult the session if one already exists,
    100   // so unauthenticated bot traffic never causes a session start (which
    101   // would defeat page caching).
    102   if ($request->hasPreviousSession()) {
    103     $subscriptions = $price_category->getFullSubscriptions();
    104     foreach ($subscriptions as $subscription_id) {
    105       if (_taler_turnstile_is_subscriber($subscription_id)) {
    106         \Drupal::logger('taler_turnstile')->debug('Subscriber detected, granting access.');
    107         // Do not advertise this response as cacheable: it depends on
    108         // session state.
    109         $build['#cache']['max-age'] = 0;
    110         return;
    111       }
    112     }
    113   }
    114 
    115   // Paywall already cleared by a prior payment? Trust the cookie.
    116   /** @var \Drupal\taler_turnstile\PaivanaCookie $cookies */
    117   $cookies = \Drupal::service('taler_turnstile.cookie');
    118   if ($cookies->verify($request, $fulfillment_url)) {
    119     \Drupal::logger('taler_turnstile')->debug('Valid Paivana cookie, granting access to @url', ['@url' => $fulfillment_url]);
    120     $build['#cache']['contexts'][] = 'cookies:' . TALER_TURNSTILE_COOKIE;
    121     return;
    122   }
    123 
    124   // Not subscribed, no cookie -> render the paywall.
    125   $config = \Drupal::config('taler_turnstile.settings');
    126   $backend_url = $config->get('payment_backend_url');
    127   if (empty($backend_url)) {
    128     \Drupal::logger('taler_turnstile')->warning('No backend URL configured, cannot present paywall.');
    129     if ($config->get('grant_access_on_error') ?? TRUE) {
    130       return;
    131     }
    132     $build = [
    133       '#markup' => '<div class="taler-turnstile-error">' . t('Payment system temporarily unavailable. Please try again later.') . '</div>',
    134     ];
    135     return;
    136   }
    137 
    138   $template_id = $price_category->getTemplateId();
    139   // Maximum lifetime of the eventually-issued cookie, in seconds.
    140   // Mirrors what we publish on the merchant template.
    141   $max_pickup_delay = \Drupal\taler_turnstile\TalerMerchantApiService::ORDER_VALIDITY_SECONDS;
    142 
    143   // Pre-compute the taler:// pay-template URI so we can both
    144   // advertise it as the Paivana header (for non-JS Taler clients)
    145   // and let the JS render its QR code from the same string.
    146   $proto_suffix = (parse_url($backend_url, PHP_URL_SCHEME) === 'http') ? '+http' : '';
    147   $host = parse_url($backend_url, PHP_URL_HOST);
    148   $port = parse_url($backend_url, PHP_URL_PORT);
    149   $path = rtrim(parse_url($backend_url, PHP_URL_PATH) ?? '/', '/');
    150   $authority = $host . ($port ? ':' . $port : '');
    151   $taler_uri = 'taler' . $proto_suffix . '://pay-template/' . $authority . $path . '/' . rawurlencode($template_id);
    152 
    153   $pay_button = [
    154     '#theme' => 'taler_turnstile_payment_button',
    155     '#merchant_backend' => $backend_url,
    156     '#template_id' => $template_id,
    157     '#max_pickup_delay' => $max_pickup_delay,
    158     '#node_title' => $node->getTitle(),
    159     '#price_hint' => $price_category->getPriceHint(),
    160     '#subscription_hint' => $price_category->getSubscriptionHint(),
    161     '#fulfillment_url' => $fulfillment_url,
    162     '#confirm_url' => \Drupal\Core\Url::fromRoute('taler_turnstile.paivana_confirm', [], ['absolute' => FALSE])->toString(),
    163     '#paivana_uri' => $taler_uri,
    164     '#attached' => [
    165       'library' => ['taler_turnstile/payment_button'],
    166       // Surface the Paivana URI as an HTTP header for non-JS clients,
    167       // and let downstream caches key on the cookie.
    168       'http_header' => [
    169         ['Paivana', $taler_uri, FALSE],
    170         ['Vary', 'Cookie', FALSE],
    171       ],
    172     ],
    173   ];
    174 
    175   $view_builder = \Drupal::entityTypeManager()->getViewBuilder('node');
    176   $teaser_build = $view_builder->view($entity, 'teaser');
    177 
    178   $build = [
    179     // Page is identical for everyone *without* the cookie, so it
    180     // remains cacheable. The cookie context only kicks in once the
    181     // visitor has paid.
    182     '#cache' => [
    183       'contexts' => array_merge(
    184         $build['#cache']['contexts'] ?? [],
    185         ['cookies:' . TALER_TURNSTILE_COOKIE]
    186       ),
    187       'tags' => Cache::mergeTags(
    188         $build['#cache']['tags'] ?? [],
    189         $price_category->getCacheTags()
    190       ),
    191     ],
    192     '#weight' => $build['#weight'] ?? 0,
    193   ];
    194 
    195   $build['teaser'] = [
    196     '#type' => 'container',
    197     '#attributes' => ['class' => ['taler-turnstile-teaser-wrapper']],
    198     'content' => $teaser_build,
    199     '#weight' => 0,
    200   ];
    201   $build['payment_button'] = [
    202     '#type' => 'container',
    203     '#attributes' => ['class' => ['taler-turnstile-payment-wrapper']],
    204     'button' => $pay_button,
    205     '#weight' => 10,
    206   ];
    207 
    208 }
    209 
    210 
    211 /**
    212  * Returns TRUE if the visitor's session has a non-expired entry for
    213  * the given subscription slug. Does NOT start a session if none yet
    214  * exists.
    215  */
    216 function _taler_turnstile_is_subscriber($subscription_slug) {
    217   $request = \Drupal::request();
    218   if (! $request->hasPreviousSession()) {
    219     return FALSE;
    220   }
    221   $session = $request->getSession();
    222   $access_data = $session->get('taler_turnstile_subscriptions', []);
    223   return ($access_data[$subscription_slug] ?? 0) >= time();
    224 }
    225 
    226 
    227 /**
    228  * Helper used by the Paivana confirmation controller to record that
    229  * a paid contract bought a subscription valid until $expiration.
    230  */
    231 function _taler_turnstile_grant_subscriber_access($subscription_slug, $expiration) {
    232   $session = \Drupal::request()->getSession();
    233   $access_data = $session->get('taler_turnstile_subscriptions', []);
    234   $access_data[$subscription_slug] = $expiration;
    235   $session->set('taler_turnstile_subscriptions', $access_data);
    236 }
    237 
    238 
    239 /**
    240  * Implements hook_theme().
    241  */
    242 function taler_turnstile_theme() {
    243   return [
    244     'taler_turnstile_payment_button' => [
    245       'variables' => [
    246         'merchant_backend' => NULL,
    247         'template_id' => NULL,
    248         'max_pickup_delay' => 86400,
    249         'node_title' => NULL,
    250         'price_hint' => NULL,
    251         'subscription_hint' => NULL,
    252         'fulfillment_url' => NULL,
    253         'confirm_url' => NULL,
    254         'paivana_uri' => NULL,
    255       ],
    256       'template' => 'taler-turnstile-payment-button',
    257     ],
    258     'taler_turnstile_settings' => [
    259       'variables' => [
    260         'config' => NULL,
    261       ],
    262     ],
    263   ];
    264 }