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 }