paivana

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

commit b84b51121110066af47452c8dc4e5ad92049b5ed
parent 3a485f2a0146273bc8a7b6c207660d2bf037fba0
Author: Christian Grothoff <christian@grothoff.org>
Date:   Wed, 22 Apr 2026 00:29:32 +0200

sketch HTML paywall page

Diffstat:
Mcontrib/paywall.en.must | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Msrc/backend/paivana-httpd_templates.c | 26++++++++++++++++++++------
2 files changed, 150 insertions(+), 63 deletions(-)

diff --git a/contrib/paywall.en.must b/contrib/paywall.en.must @@ -1,71 +1,143 @@ <!DOCTYPE html> - <html lang="en"> <head> - <meta charset="utf-8" /> + <title>Payment Required</title> + <!-- FIXME: probably should serve this ourselves... --> + <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" + integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSQX0FslNhTDadL4O5SAGapGt4FodqL8My0mA==" + crossorigin="anonymous" referrerpolicy="no-referrer"></script> + </head> + <body> + <h1>Payment Required</h1> + <p>Scan the QR code or follow the link below with your + <a href="https://wallet.taler.net">GNU Taler</a> wallet.</p> + + <div id="qrcode"></div> + <p><a id="talerlink" href="#">Generating payment link…</a></p> + <p id="statusmsg">Waiting for payment…</p> + <script> - function makeBadInsecureId(length) { - var result = ""; - var characters = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - var charactersLength = characters.length; - for (var i = 0; i < length; i++) { - result += characters.charAt( - Math.floor(Math.random() * charactersLength) - ); + (async () => { + + function waitMs(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + + function crockford32(bytes) { + // Encode a Uint8Array as Crockford base32 (no padding). + let result = '', bits = 0, val = 0; + for (const byte of bytes) { + val = (val << 8) | byte; + bits += 8; + while (bits >= 5) { + bits -= 5; + result += CROCKFORD[(val >> bits) & 0x1f]; + } } + if (bits > 0) result += CROCKFORD[(val << (5 - bits)) & 0x1f]; return result; } - function waitMs(n) { - return new Promise((resolve, reject) => { - setTimeout(() => resolve(), n); - }); + + async function sha512b32(str) { + const buf = await crypto.subtle.digest( + 'SHA-512', new TextEncoder().encode(str)); + return crockford32(new Uint8Array(buf)); + } + + async function makePaivanaId(curTime, nonce, website) { + const hash = await sha512b32(nonce + website + String(curTime)); + return `${curTime}-${hash}`; + } + + const website = `${window.location.protocol}//${window.location.host}`; + const metaMerchant = '{{merchant_backend}}'; + const metaTemplate = '{{template_id}}'; + const maxPickupDelay = {{max_pickup_delay}}; // in seconds + // cap at 100 years, we don't deal well with 'forever' otherwise + const usePickupDelay = Math.min(maxPickupDelay, 60*60*24*356*100); + + // Strip trailing slash so we can append /templateId cleanly. + const merchantBase = metaMerchant.content.replace(/\/$/, ''); + const merchantProto = new URL(merchantBase).protocol; + const suffix = merchantProto === 'http:' ? '+http' : ''; + + const expTime = Math.floor(Date.now() / 1000) + maxPickupDelay; + const nonceArr = new Uint8Array(32); + crypto.getRandomValues(nonceArr); + const nonce = Array.from(nonceArr) + .map(b => b.toString(16).padStart(2, '0')).join(''); + + const paivanaId = await makePaivanaId(expTime, nonce, website); + + const talerUri = [ + `taler${suffix}://pay-template/`, + merchantBase, + metaTemplate, + `?session_id=${encodeURIComponent(paivanaId)}`, + `&fulfillment_url=${encodeURIComponent(website)}` + ].join(''); + + const talerLink = document.getElementById('talerlink'); + talerLink.href = talerUri; + talerLink.textContent = talerUri; + + new QRCode(document.getElementById('qrcode'), { + text: talerUri, + width: 200, + height: 200, + correctLevel: QRCode.CorrectLevel.M + }); + + const statusMsg = document.getElementById('statusmsg'); + + async function confirmPayment(orderId) { + statusMsg.textContent = 'Payment confirmed! Activating access…'; + try { + const res = await fetch(`${website}/.well-known/paivana`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order_id: orderId, nonce, cur_time: expTime, website }), + }); + const dest = res.redirected ? res.url : website; + // FIXME: 400 just for testing... + await waitMs(400); + location.href = dest; + } catch (e) { + console.warn('[paivana] confirm error:', e); + statusMsg.textContent = 'Could not reach the server — please reload.'; + } } - addEventListener("DOMContentLoaded", async (event) => { - // Bad for testing, only works with http - //const paivana_id = crypto.randomUUID(); - - const paivanaId = makeBadInsecureId(16); - const proto = window.location.protocol; - const suffix = proto === "http:" ? "+http" : ""; - - const host = window.location.host; - document - .getElementById("talerlink") - .setAttribute( - "href", - `taler${suffix}://pay-template/${host}/.well-known/paivana/paywall?session_id=${paivanaId}` - ); - - while (true) { - const start = performance.now(); - try { - const res = await fetch( - `${proto}//${host}/.well-known/paivana/paivanas/${paivanaId}?timeout_ms=30000` - ); - if (res.status === 200) { - location.reload(); + + const pollBase = `${website}/.well-known/paivana/sessions/` + + encodeURIComponent(paivanaId); + while (true) { + const start = performance.now(); + try { + const res = await fetch(`${pollBase}?timeout_ms=30000`, { cache: 'no-store' }); + if (res.status === 200) { + let orderId = null; + try { orderId = (await res.json()).order_id ?? null; } catch (_) {} + if (orderId) { + await confirmPayment(orderId); } else { - console.log("payment not ready yet"); + statusMsg.textContent = 'Invalid response! Reloading anyway…'; + // FIXME: for testing... + await waitMs(400); + location.href = website; } - } catch (e) { - console.log("oops", e); - } - // FIXME: Wait depending on how long we needed - const durMs = performance.now() - start; - const remMs = Math.round(30000 - durMs); - if (remMs > 0) { - console.log(`long-poller returned early, waiting for ${remMs}ms`); - await waitMs(remMs); - } else { - console.log(`trying again immediately`); + return; } + } catch (e) { + console.warn('[paivana] poll error:', e); + statusMsg.textContent = 'Network error — retrying…'; } - }); + const remMs = Math.round(30_000 - (performance.now() - start)); + if (remMs > 0) await waitMs(remMs); + } + + })(); </script> - </head> - <body> - <h1>Payment Required</h1> - <a id="talerlink" href="...">Taler Payment</a> </body> -</html> +</html> +\ No newline at end of file diff --git a/src/backend/paivana-httpd_templates.c b/src/backend/paivana-httpd_templates.c @@ -63,6 +63,11 @@ struct Template char *template_id; /** + * Maximum pickup delay for the pages. + */ + struct GNUNET_TIME_Relative max_pickup_delay; + + /** * Regular expression of websites the template is for. */ char *regex; @@ -106,10 +111,13 @@ static struct TALER_MERCHANT_GetPrivateTemplatesHandle *gpt; * Try to initialize the paywall response. * * @param template_id template to use for the response + * @param max_pickup_delay how long does the user have to access the site + * (relative expiration time of the paivana cookie they pay for) * @return HTTP response to return for matching websites */ static struct MHD_Response * -load_paywall (const char *template_id) +load_paywall (const char *template_id, + struct GNUNET_TIME_Relative max_pickup_delay) { void *result; size_t result_size; @@ -118,10 +126,15 @@ load_paywall (const char *template_id) json_t *data; data = GNUNET_JSON_PACK ( - GNUNET_JSON_pack_string ("template_id", - template_id), - GNUNET_JSON_pack_string ("merchant_backend", - PH_merchant_base_url)); + GNUNET_JSON_pack_string ( + "template_id", + template_id), + GNUNET_JSON_pack_uint64 ( + "max_pickup_delay", + max_pickup_delay.rel_value_us / 1000LLU / 1000LLU), + GNUNET_JSON_pack_string ( + "merchant_backend", + PH_merchant_base_url)); if (0 != TALER_TEMPLATING_fill ("paywall", data, @@ -227,7 +240,8 @@ setup_template ( } if (t->regex) { - t->paywall = load_paywall (t->template_id); + t->paywall = load_paywall (t->template_id, + t->max_pickup_delay); } for (struct Template *p = t_head; NULL != p; p = p->next) if (NULL != p->gt)