turnstile

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

payment-button.js (6508B)


      1 /**
      2  * @file payment-button.js
      3  * @brief Paivana-style paywall client for the GNU Taler Turnstile module.
      4  *
      5  * Generates a fresh "paivana_id" client-side, builds a
      6  * taler://pay-template/... URI from the merchant backend + template
      7  * advertised on the page, long-polls the merchant's session endpoint,
      8  * and then asks the Drupal site to mint an access cookie once payment
      9  * was observed.
     10  *
     11  * @license LGPLv3+
     12  */
     13 
     14 (function ($, Drupal, once) {
     15   'use strict';
     16 
     17   function waitMs(ms) {
     18     return new Promise(function (resolve) { setTimeout(resolve, ms); });
     19   }
     20 
     21   function toBase64Url(bytes) {
     22     var binary = '';
     23     for (var i = 0; i < bytes.length; i++) {
     24       binary += String.fromCharCode(bytes[i]);
     25     }
     26     return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
     27   }
     28 
     29   function sha256b64(str) {
     30     return crypto.subtle.digest('SHA-256', new TextEncoder().encode(str))
     31       .then(function (buf) { return toBase64Url(new Uint8Array(buf)); });
     32   }
     33 
     34   function makePaivanaId(curTime, nonce, website) {
     35     return sha256b64(nonce + website + String(curTime)).then(function (hash) {
     36       return curTime + '-' + hash;
     37     });
     38   }
     39 
     40   function buildTalerUri(paivanaUri, paivanaId, fulfillmentUrl) {
     41     var sep = paivanaUri.indexOf('?') === -1 ? '?' : '&';
     42     return paivanaUri + sep
     43          + 'session_id=' + encodeURIComponent(paivanaId)
     44          + '&fulfillment_url=' + encodeURIComponent(fulfillmentUrl);
     45   }
     46 
     47   function setStatus($container, msg) {
     48     $container.find('.taler-turnstile-status-message').text(msg);
     49   }
     50 
     51   function startFlow(container) {
     52     var $container = $(container);
     53     var merchantBackend = $container.data('merchant-backend');
     54     var templateId = $container.data('template-id');
     55     var fulfillmentUrl = $container.data('fulfillment-url');
     56     var confirmUrl = $container.data('confirm-url');
     57     var paivanaUri = $container.data('paivana-uri');
     58     var maxPickupDelay = parseInt($container.data('max-pickup-delay'), 10) || 86400;
     59     if (!merchantBackend || !templateId || !confirmUrl || !paivanaUri) {
     60       console.error('[turnstile] required data attributes missing on payment container', $container[0]);
     61       return;
     62     }
     63     // Cap the pickup window so we don't overflow time math, but
     64     // otherwise honor what the server template advertised.
     65     var pickup = Math.min(maxPickupDelay, 60 * 60 * 24 * 365 * 100);
     66     var curTime = Math.floor(Date.now() / 1000) + pickup;
     67     var nonceArr = new Uint8Array(32);
     68     crypto.getRandomValues(nonceArr);
     69     var nonce = Array.from(nonceArr)
     70       .map(function (b) { return b.toString(16).padStart(2, '0'); })
     71       .join('');
     72 
     73     makePaivanaId(curTime, nonce, fulfillmentUrl).then(function (paivanaId) {
     74       var talerUri = buildTalerUri(paivanaUri, paivanaId, fulfillmentUrl);
     75 
     76       // QR + click-through link.
     77       var $qr = $container.find('.taler-turnstile-qr-code-container').first();
     78       $qr.empty();
     79       if (typeof QRCode !== 'undefined') {
     80         new QRCode($qr[0], {
     81           text: talerUri,
     82           width: 200,
     83           height: 200,
     84           colorDark: '#000000',
     85           colorLight: '#ffffff',
     86           correctLevel: QRCode.CorrectLevel.M
     87         });
     88       }
     89       $container.find('.taler-turnstile-pay-button').attr('href', talerUri);
     90 
     91       pollSession({
     92         $container: $container,
     93         merchantBackend: merchantBackend,
     94         paivanaId: paivanaId,
     95         nonce: nonce,
     96         curTime: curTime,
     97         fulfillmentUrl: fulfillmentUrl,
     98         confirmUrl: confirmUrl
     99       });
    100     }).catch(function (e) {
    101       console.error('[turnstile] failed to compute paivana id:', e);
    102       setStatus($container, Drupal.t('Could not initialize payment.'));
    103     });
    104   }
    105 
    106   function pollSession(ctx) {
    107     var pollUrl = ctx.merchantBackend.replace(/\/$/, '')
    108                 + '/sessions/' + encodeURIComponent(ctx.paivanaId)
    109                 + '?timeout_ms=30000'
    110                 + '&fulfillment_url=' + encodeURIComponent(ctx.fulfillmentUrl);
    111     var start = performance.now();
    112     fetch(pollUrl, { cache: 'no-store' })
    113       .then(function (res) {
    114         if (res.status === 200) {
    115           return res.json().then(function (j) {
    116             return confirmPayment(ctx, j.order_id);
    117           });
    118         }
    119         // 202 = unpaid (long poll returned), 404 = no order yet,
    120         // anything else is treated as transient.
    121         var rem = 30000 - (performance.now() - start);
    122         if (rem < 0) rem = 0;
    123         return waitMs(rem).then(function () { pollSession(ctx); });
    124       })
    125       .catch(function (e) {
    126         console.warn('[turnstile] poll error:', e);
    127         setStatus(ctx.$container, Drupal.t('Network error. Retrying...'));
    128         waitMs(5000).then(function () { pollSession(ctx); });
    129       });
    130   }
    131 
    132   function confirmPayment(ctx, orderId) {
    133     setStatus(ctx.$container, Drupal.t('Payment confirmed! Loading page...'));
    134     return fetch(ctx.confirmUrl, {
    135       method: 'POST',
    136       headers: { 'Content-Type': 'application/json' },
    137       credentials: 'same-origin',
    138       body: JSON.stringify({
    139         order_id: orderId,
    140         nonce: ctx.nonce,
    141         cur_time: ctx.curTime,
    142         website: ctx.fulfillmentUrl
    143       })
    144     }).then(function (res) {
    145       // Server responds 303 with the cookie + Location; fetch follows
    146       // redirects transparently, so res.url is the final destination.
    147       var dest = (res.redirected && res.url) ? res.url : ctx.fulfillmentUrl;
    148       window.location.href = dest;
    149     }).catch(function (e) {
    150       console.warn('[turnstile] confirm error:', e);
    151       setStatus(ctx.$container, Drupal.t('Could not reach the server.'));
    152     });
    153   }
    154 
    155   Drupal.behaviors.talerTurnstilePaymentButton = {
    156     attach: function (context, settings) {
    157       // Detect taler:// support exactly once.
    158       once('taler-support', 'html').forEach(function () {
    159         window.talerCallback = function (res) {
    160           if (res && res.present) {
    161             $('.show-if-taler-supported').removeClass('hidden');
    162           } else {
    163             $('.show-if-taler-supported').addClass('hidden');
    164           }
    165         };
    166         var meta = document.createElement('meta');
    167         meta.name = 'taler-support';
    168         meta.content = 'api,callback';
    169         document.getElementsByTagName('head')[0].appendChild(meta);
    170       });
    171       var containers = once('taler-turnstile-payment-init', '.taler-turnstile-payment-container', context);
    172       containers.forEach(function (c) { startFlow(c); });
    173     }
    174   };
    175 
    176 })(jQuery, Drupal, once);