commit b84b51121110066af47452c8dc4e5ad92049b5ed
parent 3a485f2a0146273bc8a7b6c207660d2bf037fba0
Author: Christian Grothoff <christian@grothoff.org>
Date: Wed, 22 Apr 2026 00:29:32 +0200
sketch HTML paywall page
Diffstat:
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)