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);