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 }