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