turnstile

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

payment-button.js (9073B)


      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   // GNUnet's Crockford base32 alphabet (omits I, L, O, U).
     30   var CROCK_TABLE = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
     31 
     32   function encodeCrock(bytes) {
     33     var sb = '';
     34     var size = bytes.length;
     35     var bitBuf = 0;
     36     var numBits = 0;
     37     var pos = 0;
     38     while (pos < size || numBits > 0) {
     39       if (pos < size && numBits < 5) {
     40         bitBuf = (bitBuf << 8) | bytes[pos++];
     41         numBits += 8;
     42       }
     43       if (numBits < 5) {
     44         bitBuf = bitBuf << (5 - numBits);
     45         numBits = 5;
     46       }
     47       var v = (bitBuf >>> (numBits - 5)) & 31;
     48       sb += CROCK_TABLE[v];
     49       numBits -= 5;
     50     }
     51     return sb;
     52   }
     53 
     54   // 8-byte big-endian microseconds, matching GNUNET_TIME_AbsoluteNBO.
     55   function timestampToBE64Microseconds(sec) {
     56     var b = new ArrayBuffer(8);
     57     var v = new DataView(b);
     58     v.setBigUint64(0, BigInt(sec) * 1000000n);
     59     return new Uint8Array(b);
     60   }
     61 
     62   // Canonical paivana ID: <seconds> "-" b64url(SHA256(
     63   //   nonce_bytes || website || "\0" || us_BE64))
     64   // See paivana-httpd_cookie.c::PAIVANA_HTTPD_compute_paivana_id.
     65   function makePaivanaId(curTime, nonceBuf, website) {
     66     var websiteBuf = new TextEncoder().encode(website + '\0');
     67     var curTimeBuf = timestampToBE64Microseconds(curTime);
     68     var buf = new Uint8Array(nonceBuf.length + websiteBuf.length + curTimeBuf.length);
     69     buf.set(nonceBuf, 0);
     70     buf.set(websiteBuf, nonceBuf.length);
     71     buf.set(curTimeBuf, nonceBuf.length + websiteBuf.length);
     72     return crypto.subtle.digest('SHA-256', buf).then(function (hashBuf) {
     73       return curTime + '-' + toBase64Url(new Uint8Array(hashBuf));
     74     });
     75   }
     76 
     77   function buildTalerUri(paivanaUri, paivanaId, fulfillmentUrl) {
     78     var sep = paivanaUri.indexOf('?') === -1 ? '?' : '&';
     79     return paivanaUri + sep
     80          + 'session_id=' + encodeURIComponent(paivanaId)
     81          + '&fulfillment_url=' + encodeURIComponent(fulfillmentUrl);
     82   }
     83 
     84   function setStatus($container, msg) {
     85     $container.find('.taler-turnstile-status-message').text(msg);
     86   }
     87 
     88   function startFlow(container) {
     89     var $container = $(container);
     90     var merchantBackend = $container.data('merchant-backend');
     91     var templateId = $container.data('template-id');
     92     var fulfillmentUrl = $container.data('fulfillment-url');
     93     var confirmUrl = $container.data('confirm-url');
     94     var paivanaUri = $container.data('paivana-uri');
     95     var maxPickupDelay = parseInt($container.data('max-pickup-delay'), 10) || 86400;
     96     if (!merchantBackend || !templateId || !confirmUrl || !paivanaUri) {
     97       console.error('[turnstile] required data attributes missing on payment container', $container[0]);
     98       return;
     99     }
    100     // Cap the pickup window so we don't overflow time math, but
    101     // otherwise honor what the server template advertised.
    102     var pickup = Math.min(maxPickupDelay, 60 * 60 * 24 * 365 * 100);
    103     var curTime = Math.floor(Date.now() / 1000) + pickup;
    104     // Canonical PAIVANA_Nonce is 16 bytes (4 * uint32_t).
    105     var nonceBuf = new Uint8Array(16);
    106     crypto.getRandomValues(nonceBuf);
    107     var nonce = encodeCrock(nonceBuf);
    108 
    109     makePaivanaId(curTime, nonceBuf, fulfillmentUrl).then(function (paivanaId) {
    110       var talerUri = buildTalerUri(paivanaUri, paivanaId, fulfillmentUrl);
    111 
    112       // QR + click-through link.
    113       var $qr = $container.find('.taler-turnstile-qr-code-container').first();
    114       $qr.empty();
    115       if (typeof QRCode !== 'undefined') {
    116         new QRCode($qr[0], {
    117           text: talerUri,
    118           width: 200,
    119           height: 200,
    120           colorDark: '#000000',
    121           colorLight: '#ffffff',
    122           correctLevel: QRCode.CorrectLevel.M
    123         });
    124       }
    125       $container.find('.taler-turnstile-pay-button').attr('href', talerUri);
    126 
    127       pollSession({
    128         $container: $container,
    129         merchantBackend: merchantBackend,
    130         paivanaId: paivanaId,
    131         nonce: nonce,
    132         curTime: curTime,
    133         fulfillmentUrl: fulfillmentUrl,
    134         confirmUrl: confirmUrl
    135       });
    136     }).catch(function (e) {
    137       console.error('[turnstile] failed to compute paivana id:', e);
    138       setStatus($container, Drupal.t('Could not initialize payment.'));
    139     });
    140   }
    141 
    142   // Stop after this many consecutive hard failures (4xx other than
    143   // 404, 5xx, or network errors). 404 and 202 are expected
    144   // intermediate states and reset the counter.
    145   var MAX_POLL_ERRORS = 5;
    146 
    147 
    148   function pollSession(ctx) {
    149     if (typeof ctx.errorCount !== 'number') {
    150       ctx.errorCount = 0;
    151     }
    152     var pollUrl = ctx.merchantBackend.replace(/\/$/, '')
    153                 + '/sessions/' + encodeURIComponent(ctx.paivanaId)
    154                 + '?timeout_ms=30000'
    155                 + '&fulfillment_url=' + encodeURIComponent(ctx.fulfillmentUrl);
    156     var start = performance.now();
    157     fetch(pollUrl, { cache: 'no-store' })
    158       .then(function (res) {
    159         if (res.status === 200) {
    160           ctx.errorCount = 0;
    161             return res.json().then(function (j) {
    162               if (!j || !j.order_id) {
    163                 throw new Error('200 without order_id');
    164               }
    165               return confirmPayment(ctx, j.order_id);
    166           });
    167         }
    168         // 202 = unpaid (long poll returned), 404 = no order yet.
    169         // Everything else (400, 401/403, 5xx, ...) is a hard error.
    170         var transient = (res.status === 202 || res.status === 404);
    171         if (transient) {
    172           ctx.errorCount = 0;
    173         }
    174         else {
    175           ctx.errorCount++;
    176           console.warn('[turnstile] poll HTTP',
    177                        res.status,
    178                        '(', ctx.errorCount, '/', MAX_POLL_ERRORS, ')');
    179           if (ctx.errorCount >= MAX_POLL_ERRORS) {
    180             setStatus(ctx.$container, Drupal.t(
    181               'Payment service is unavailable. Please reload the page to try again.'));
    182             return;
    183           }
    184         }
    185         var rem = 30000 - (performance.now() - start);
    186         if (rem < 0) rem = 0;
    187         return waitMs(rem).then(function () { pollSession(ctx); });
    188       })
    189       .catch(function (e) {
    190          ctx.errorCount = (ctx.errorCount || 0) + 1;
    191          console.warn('[turnstile] poll error:', e,
    192                       '(', ctx.errorCount, '/', MAX_POLL_ERRORS, ')');
    193         if (ctx.errorCount >= MAX_POLL_ERRORS) {
    194           setStatus(ctx.$container, Drupal.t(
    195             'Network unavailable. Please reload the page to try again.'));
    196           return;
    197         }
    198         setStatus(ctx.$container, Drupal.t('Network error. Retrying...'));
    199         waitMs(5000).then(function () { pollSession(ctx); });
    200       });
    201   }
    202 
    203   function confirmPayment(ctx, orderId) {
    204     setStatus(ctx.$container, Drupal.t('Payment confirmed! Loading page...'));
    205     return fetch(ctx.confirmUrl, {
    206       method: 'POST',
    207       headers: { 'Content-Type': 'application/json' },
    208       credentials: 'same-origin',
    209       body: JSON.stringify({
    210         order_id: orderId,
    211         nonce: ctx.nonce,
    212         cur_time: { t_s: ctx.curTime },
    213         website: ctx.fulfillmentUrl
    214       })
    215     }).then(function (res) {
    216       // Server responds 303 with the cookie + Location; fetch follows
    217       // redirects transparently, so res.url is the final destination.
    218       var dest = (res.redirected && res.url) ? res.url : ctx.fulfillmentUrl;
    219       window.location.href = dest;
    220     }).catch(function (e) {
    221       console.warn('[turnstile] confirm error:', e);
    222       setStatus(ctx.$container, Drupal.t('Could not reach the server.'));
    223     });
    224   }
    225 
    226   Drupal.behaviors.talerTurnstilePaymentButton = {
    227     attach: function (context, settings) {
    228       // Detect taler:// support exactly once.
    229       once('taler-support', 'html').forEach(function () {
    230         window.talerCallback = function (res) {
    231           if (res && res.present) {
    232             $('.show-if-taler-supported').removeClass('hidden');
    233           } else {
    234             $('.show-if-taler-supported').addClass('hidden');
    235           }
    236         };
    237         var meta = document.createElement('meta');
    238         meta.name = 'taler-support';
    239         meta.content = 'api,callback';
    240         document.getElementsByTagName('head')[0].appendChild(meta);
    241       });
    242       var containers = once('taler-turnstile-payment-init', '.taler-turnstile-payment-container', context);
    243       containers.forEach(function (c) { startFlow(c); });
    244     }
    245   };
    246 
    247 })(jQuery, Drupal, once);