paywall.js (8562B)
1 /* 2 This file is part of GNU Taler 3 (C) 2026 Taler Systems S.A. 4 5 GNU Taler is free software; you can redistribute it and/or modify it under the 6 terms of the GNU Affero General Public License as published by the Free Software 7 Foundation; either version 3, or (at your option) any later version. 8 9 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 10 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. 12 13 You should have received a copy of the GNU Affero General Public License along with 14 GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 // @ts-check 18 19 const website = atob(window.location.hash.substring(1)); 20 const encTable = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; 21 // cap at 100 years, we don't deal well with 'forever' otherwise 22 const usePickupDelay = Math.min(MAX_PICKUP_DELAY, 60 * 60 * 24 * 365 * 100); 23 24 // Strip trailing slash so we can append /templateId cleanly. 25 const merchantBase = MERCHANT_BACKEND.replace(/\/$/, ""); 26 const merchantUrl = new URL(merchantBase); 27 const merchantProto = merchantUrl.protocol; 28 const suffix = merchantProto === "http:" ? "+http" : ""; 29 const merchantHost = merchantUrl.host; 30 const merchantPath = merchantUrl.pathname; 31 const expTime = Math.floor(Date.now() / 1000) + usePickupDelay; 32 33 /** 34 * @param {ArrayBuffer} data 35 * @returns {string} 36 */ 37 function encodeCrock(data) { 38 const dataBytes = new Uint8Array(data); 39 let sb = ""; 40 const size = data.byteLength; 41 let bitBuf = 0; 42 let numBits = 0; 43 let pos = 0; 44 while (pos < size || numBits > 0) { 45 if (pos < size && numBits < 5) { 46 const d = dataBytes[pos++]; 47 bitBuf = (bitBuf << 8) | d; 48 numBits += 8; 49 } 50 if (numBits < 5) { 51 bitBuf = bitBuf << (5 - numBits); 52 numBits = 5; 53 } 54 const v = (bitBuf >>> (numBits - 5)) & 31; 55 sb += encTable[v]; 56 numBits -= 5; 57 } 58 return sb; 59 } 60 61 /** 62 * @param {number} sec 63 * @returns {Uint8Array} 64 */ 65 function timestampRoundedToBuffer(sec) { 66 const b = new ArrayBuffer(8); 67 const v = new DataView(b); 68 const numVal = BigInt(sec) * 1000n * 1000n; 69 // The buffer we sign over represents the timestamp in microseconds. 70 v.setBigUint64(0, numVal); 71 return new Uint8Array(b); 72 } 73 74 /** 75 * @param {number} ms 76 * @returns {Promise<void>} 77 */ 78 function waitMs(ms) { 79 return new Promise((resolve) => setTimeout(resolve, ms)); 80 } 81 82 /** 83 * Encode @a bytes in the RFC 4648 section 5 URL-safe alphabet, with 84 * the padding stripped -- the same form the daemon produces with 85 * GNUNET_STRINGS_base64url_encode(), and the mirror of the decode at 86 * the top of this file. 87 * 88 * Deliberately built out of btoa() rather than the much tidier 89 * `bytes.toBase64({alphabet: "base64url"})'. That method is a 2024-25 90 * addition (Firefox 133, Safari 18.2, Chrome 140), so on anything 91 * older it is simply not a function -- and this runs on the payment 92 * path, where the exception would leave a paywall that cannot be paid 93 * with no indication why. That is the same failure the decode above 94 * exists to prevent, and it is not worth reintroducing at the other 95 * end for the sake of one line. 96 * 97 * @param {Uint8Array} bytes 98 * @returns {string} 99 */ 100 function base64url(bytes) { 101 let s = ""; 102 103 /* Not String.fromCharCode(...bytes): spreading a large array 104 overflows the argument limit. The digests here are 32 bytes, but 105 the loop costs nothing and does not have to be re-examined if a 106 caller ever passes something bigger. */ 107 for (const b of bytes) { 108 s += String.fromCharCode(b); 109 } 110 return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); 111 } 112 113 /** 114 * @param {BufferSource} data 115 * @returns {Promise<string>} 116 */ 117 async function sha256b64(data) { 118 const buf = await crypto.subtle.digest("SHA-256", data); 119 return base64url(new Uint8Array(buf)); 120 } 121 122 /** 123 * @param {number} curTime 124 * @param {Uint8Array} nonceBuf 125 * @param {string} website 126 * @returns {Promise<string>} 127 */ 128 async function makePaivanaId(curTime, nonceBuf, website) { 129 const websiteBuf = new TextEncoder().encode(`${website}\0`); 130 const curTimeBuf = timestampRoundedToBuffer(curTime); 131 132 const length = nonceBuf.length + websiteBuf.length + curTimeBuf.length; 133 const buf = new Uint8Array(length); 134 buf.set(nonceBuf, 0); 135 buf.set(websiteBuf, nonceBuf.length); 136 buf.set(curTimeBuf, nonceBuf.length + websiteBuf.length); 137 const hash = await sha256b64(buf); 138 return `${curTime}-${hash}`; 139 } 140 141 /** 142 * @param {string} order_id 143 * @param {HTMLElement} linkEl 144 * @param {HTMLElement} errorEl 145 * @param {string} nonce 146 * @returns {Promise<void>} 147 */ 148 async function confirmPayment(order_id, linkEl, errorEl, nonce) { 149 linkEl.textContent = I18N_PAYMENT_CONFIRMED_LOADING; 150 // @ts-ignore 151 linkEl.href = "#"; 152 try { 153 const res = await fetch(`${window.location.origin}/.well-known/paivana`, { 154 method: "POST", 155 headers: { "Content-Type": "application/json" }, 156 redirect: "manual", 157 body: JSON.stringify({ 158 order_id, 159 nonce, 160 expiration: { t_s: expTime }, 161 website, 162 }), 163 }); 164 if (res.status >= 400) { 165 linkEl.textContent = I18N_PAYMENT_CONFIRMED_PROBLEM; 166 errorEl.textContent = JSON.stringify(await res.json()); 167 return; 168 } 169 const dest = res.redirected ? res.url : website; 170 location.href = dest; 171 } catch (e) { 172 console.warn("[paivana] Error trying to confirm payment:", e); 173 errorEl.textContent = I18N_PAYMENT_CONFIRMED_ERROR; 174 } 175 } 176 177 /** 178 * @param {HTMLElement} el 179 * @returns {void} 180 */ 181 function toggleDescription(el) { 182 for (const a of el.getElementsByClassName("arrow")) { 183 a.classList.toggle("upside-down"); 184 } 185 for (const a of el.getElementsByClassName("price-list")) { 186 a.classList.toggle("hidden"); 187 } 188 } 189 190 async function main() { 191 const nonceBuf = new Uint8Array(16); 192 crypto.getRandomValues(nonceBuf); 193 const nonce = encodeCrock(nonceBuf.buffer); 194 195 const paivanaId = await makePaivanaId(expTime, nonceBuf, website); 196 197 // finally we can compute the talerURI and polling URL 198 const TALER_URI = [ 199 `taler${suffix}://pay-template/`, 200 merchantHost, 201 merchantPath, 202 `/`, 203 MERCHANT_TEMPLATE_ID, 204 `?session_id=${encodeURIComponent(paivanaId)}`, 205 `&fulfillment_url=${encodeURIComponent(website)}`, 206 ].join(""); 207 208 const PAIVANA_POLL_URL = [ 209 merchantBase, 210 "/sessions/", 211 encodeURIComponent(paivanaId), 212 "?fulfillment_url=", 213 encodeURIComponent(website), 214 "&timeout_ms=", 215 POLL_WAIT_MS, 216 ].join(""); 217 218 // grab all the html element we need 219 const talerLink = document.getElementById("taler-link"); 220 const errorMessageLabel = document.getElementById("error-message"); 221 const qrDiv = document.getElementById("qrcode"); 222 223 if (talerLink) { 224 // show the taler URI as a link 225 // because we want the user to be able to use the webex 226 // @ts-ignore 227 talerLink.href = TALER_URI; 228 } 229 230 // show the qr code 231 new QRCode(qrDiv, { 232 text: TALER_URI, 233 width: QR_WIDTH, 234 height: QR_HEIGHT, 235 correctLevel: QRCode.CorrectLevel.M, 236 }); 237 238 // From here we just poll. Whe the request from polling 239 // returns that the order has been paid we show to the 240 // proxy that we hold the nonce and it should return 241 // us a valid cookie. 242 while (true) { 243 const start = performance.now(); 244 try { 245 const res = await fetch(PAIVANA_POLL_URL, { cache: "no-store" }); 246 if (res.status === 200) { 247 let info = null; 248 try { 249 info = await res.json(); 250 } catch (_) {} 251 console.log("[paivana] Got reponse from backend", res, info); 252 if (info && info.order_id) { 253 if (talerLink && errorMessageLabel) { 254 await confirmPayment( 255 info.order_id, 256 talerLink, 257 errorMessageLabel, 258 nonce, 259 ); 260 } 261 } else { 262 if (talerLink) { 263 talerLink.textContent = I18N_PAYMENT_CONFIRMED_NO_ORDER; 264 } 265 const remMs = Math.round(POLL_WAIT_MS - (performance.now() - start)); 266 if (remMs > 0) await waitMs(remMs); 267 location.href = website; 268 } 269 return; 270 } 271 } catch (e) { 272 console.warn("[paivana] poll error:", e); 273 if (talerLink) { 274 // @ts-ignore 275 talerLink.href = "#"; 276 talerLink.textContent = I18N_PAYMENT_NETWORK_PROBLEM; 277 } 278 } 279 const remMs = Math.round(POLL_WAIT_MS - (performance.now() - start)); 280 if (remMs > 0) await waitMs(remMs); 281 } 282 }