paivana

HTTP paywall reverse proxy
Log | Files | Refs | Submodules | README | LICENSE

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 }