paivana

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

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 }