paywall.js (7355B)
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 * 356 * 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 * @param {BufferSource} data 84 * @returns {Promise<string>} 85 */ 86 async function sha256b64(data) { 87 const buf = await crypto.subtle.digest("SHA-256", data); 88 return new Uint8Array(buf) 89 .toBase64({ alphabet: "base64url" }) 90 .replace(/=+$/, ""); 91 } 92 93 /** 94 * @param {number} curTime 95 * @param {Uint8Array} nonceBuf 96 * @param {string} website 97 * @returns {Promise<string>} 98 */ 99 async function makePaivanaId(curTime, nonceBuf, website) { 100 const websiteBuf = new TextEncoder().encode(`${website}\0`); 101 const curTimeBuf = timestampRoundedToBuffer(curTime); 102 103 const length = nonceBuf.length + websiteBuf.length + curTimeBuf.length; 104 const buf = new Uint8Array(length); 105 buf.set(nonceBuf, 0); 106 buf.set(websiteBuf, nonceBuf.length); 107 buf.set(curTimeBuf, nonceBuf.length + websiteBuf.length); 108 const hash = await sha256b64(buf); 109 return `${curTime}-${hash}`; 110 } 111 112 /** 113 * @param {string} order_id 114 * @param {HTMLElement} linkEl 115 * @param {HTMLElement} errorEl 116 * @param {string} nonce 117 * @returns {Promise<void>} 118 */ 119 async function confirmPayment(order_id, linkEl, errorEl, nonce) { 120 linkEl.textContent = I18N_PAYMENT_CONFIRMED_LOADING; 121 // @ts-ignore 122 linkEl.href = "#"; 123 try { 124 const res = await fetch(`${window.location.origin}/.well-known/paivana`, { 125 method: "POST", 126 headers: { "Content-Type": "application/json" }, 127 redirect: "manual", 128 body: JSON.stringify({ 129 order_id, 130 nonce, 131 cur_time: { t_s: expTime }, 132 website, 133 }), 134 }); 135 if (res.status >= 400) { 136 linkEl.textContent = I18N_PAYMENT_CONFIRMED_PROBLEM; 137 errorEl.textContent = JSON.stringify(await res.json()); 138 } 139 const dest = res.redirected ? res.url : website; 140 location.href = dest; 141 } catch (e) { 142 console.warn("[paivana] Error trying to confirm payment:", e); 143 errorEl.textContent = I18N_PAYMENT_CONFIRMED_ERROR; 144 } 145 } 146 147 /** 148 * @param {HTMLElement} el 149 * @returns {void} 150 */ 151 function toggleDescription(el) { 152 for (const a of el.getElementsByClassName("arrow")) { 153 a.classList.toggle("upside-down"); 154 } 155 for (const a of el.getElementsByClassName("price-list")) { 156 a.classList.toggle("hidden"); 157 } 158 } 159 160 async function main() { 161 const nonceBuf = new Uint8Array(16); 162 crypto.getRandomValues(nonceBuf); 163 const nonce = encodeCrock(nonceBuf.buffer); 164 165 const paivanaId = await makePaivanaId(expTime, nonceBuf, website); 166 167 // finally we can compute the talerURI and polling URL 168 const TALER_URI = [ 169 `taler${suffix}://pay-template/`, 170 merchantHost, 171 merchantPath, 172 `/`, 173 MERCHANT_TEMPLATE_ID, 174 `?session_id=${encodeURIComponent(paivanaId)}`, 175 `&fulfillment_url=${encodeURIComponent(website)}`, 176 ].join(""); 177 178 const PAIVANA_POLL_URL = [ 179 merchantBase, 180 "/sessions/", 181 encodeURIComponent(paivanaId), 182 "?fulfillment_url=", 183 encodeURIComponent(website), 184 "&timeout_ms=", 185 POLL_WAIT_MS, 186 ].join(""); 187 188 // grab all the html element we need 189 const talerLink = document.getElementById("taler-link"); 190 const errorMessageLabel = document.getElementById("error-message"); 191 const qrDiv = document.getElementById("qrcode"); 192 193 if (talerLink) { 194 // show the taler URI as a link 195 // because we want the user to be able to use the webex 196 // @ts-ignore 197 talerLink.href = TALER_URI; 198 } 199 200 // show the qr code 201 new QRCode(qrDiv, { 202 text: TALER_URI, 203 width: QR_WIDTH, 204 height: QR_HEIGHT, 205 correctLevel: QRCode.CorrectLevel.M, 206 }); 207 208 // From here we just poll. Whe the request from polling 209 // returns that the order has been paid we show to the 210 // proxy that we hold the nonce and it should return 211 // us a valid cookie. 212 while (true) { 213 const start = performance.now(); 214 try { 215 const res = await fetch(PAIVANA_POLL_URL, { cache: "no-store" }); 216 if (res.status === 200) { 217 let info = null; 218 try { 219 info = await res.json(); 220 } catch (_) {} 221 console.log("[paivana] Got reponse from backend", res, info); 222 if (info && info.order_id) { 223 if (talerLink && errorMessageLabel) { 224 await confirmPayment( 225 info.order_id, 226 talerLink, 227 errorMessageLabel, 228 nonce, 229 ); 230 } 231 } else { 232 if (talerLink) { 233 talerLink.textContent = I18N_PAYMENT_CONFIRMED_NO_ORDER; 234 } 235 const remMs = Math.round(POLL_WAIT_MS - (performance.now() - start)); 236 if (remMs > 0) await waitMs(remMs); 237 location.href = website; 238 } 239 return; 240 } 241 } catch (e) { 242 console.warn("[paivana] poll error:", e); 243 if (talerLink) { 244 // @ts-ignore 245 talerLink.href = "#"; 246 talerLink.textContent = I18N_PAYMENT_NETWORK_PROBLEM; 247 } 248 } 249 const remMs = Math.round(POLL_WAIT_MS - (performance.now() - start)); 250 if (remMs > 0) await waitMs(remMs); 251 } 252 }