turnstile

Drupal paywall plugin
Log | Files | Refs | README | LICENSE

commit 115ae8678ff40af5e6f4f95463f9c0ee36a1fe2d
parent bd006a76c994509a602bcb8d0c070c7f9ed3964c
Author: Christian Grothoff <christian@grothoff.org>
Date:   Sun,  3 May 2026 09:09:56 +0200

use Paivana-style instead of creating an order per paywall page

Diffstat:
MREADME.md | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mjs/payment-button.js | 289+++++++++++++++++++++++++++++++++++++++----------------------------------------
Asrc/Controller/PaivanaController.php | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/Entity/TurnstilePriceCategory.php | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/EventSubscriber/TurnstileConfigSubscriber.php | 27++++++++++++++++++++++++++-
Asrc/PaivanaCookie.php | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/TalerMerchantApiService.php | 447+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mtaler_turnstile.module | 274+++++++++++++++++++++++++++++++++++--------------------------------------------
Mtaler_turnstile.routing.yml | 10++++++++++
Mtaler_turnstile.services.yml | 5+++++
Mtemplates/taler-turnstile-payment-button.html.twig | 19++++++++++---------
11 files changed, 1102 insertions(+), 474 deletions(-)

diff --git a/README.md b/README.md @@ -1,66 +1,135 @@ # GNU Taler Turnstile -A Drupal module that asks user to subscribe or pay using GNU Taler +A Drupal module that asks users to subscribe or pay using GNU Taler before granting access to nodes. +Inspired by [Paivana](https://taler.net/) (DD 76), Turnstile uses +*payment templates* in the merchant backend so that the paywall page +itself is fully static and cacheable: no order is created when a +visitor merely loads a paywalled article, and no PHP session is +started for unauthenticated traffic. Orders only get created once a +visitor's GNU Taler wallet actually picks the template up. + ## Features - Adds a "price category" field to configurable content types -- Implements access control without tracking (but does use session cookies) -- Truncates node bodies to show teasers for users that did not (yet) pay +- Static, cacheable paywall pages: bot traffic never creates orders + in the merchant backend and never starts a PHP session +- Access control without tracking — paid access is held in a + short-lived HMAC cookie, no database table maps users to articles +- Truncates node bodies to show teasers for visitors who did not (yet) pay +- Server-side amount-check on every payment confirmation: the paid + contract must match the price category configured for the + fulfillment URL, otherwise the cookie is not minted - Configurable which content types are subject to access control -- Integration with external GNU Taler merchant backend for payment processing -- Admin interface for configuration +- Subscription support with optional per-category discounts ## Installation -1. Download and extract the module to your `modules/custom/` directory +1. Download and extract the module to your `modules/custom/` directory. 2a. Enable the module via Drush: `drush en taler_turnstile`, or -2b. Enable via the Drupal admin interface at `/admin/modules` +2b. Enable via the Drupal admin interface at `/admin/modules`. ## Configuration -1. Navigate to `/admin/config/content/taler-turnstile` to configure: +1. Navigate to `/admin/config/system/taler-turnstile` to configure: -- **Enabled Content Types**: Select which content types should have the price field and access restriction -- **Payment Backend URL**: Taler merchant backend HTTP(S) URL of your payment backend service -- **Access Token**: Authentication token for the Taler merchant backend -- **Enable access if backend is down**: Disables GNU Taler Turnstile if we - cannot setup payments with the Taler merchant backend for any reason +- **Enabled Content Types**: Select which content types should have + the price field and access restriction. +- **Payment Backend URL**: HTTP(S) URL of your Taler merchant backend + (instance-specific URLs ending in `/instances/$ID/` are supported). +- **Access Token**: Bearer token authenticating Turnstile to that + merchant instance. The token must have the `templates-write` and + `orders-read` permissions. +- **Enable access if backend is down**: Fail-open toggle for the case + where the merchant backend is not configured or unreachable. -2. Make sure your Taler merchant backend is properly configured: -2a. Bank account added -2b. Legitimization as account owner with payment service provider is done -3. Configure one or more classes of subscriptions (optional) +2. Make sure your Taler merchant backend is ready: + - Bank account added + - Legitimization with the payment service provider completed -Navigate to `/admin/config/system/taler-turnstile/subscription-prices` to configure: +3. Configure one or more classes of subscriptions (optional). Navigate + to `/admin/config/system/taler_turnstile/subscription-prices` and + set the price at which each subscription token family can be + purchased. -- **Subscription prices**: Price for each type of subscription and currency +The first time you save a working backend URL + token, Turnstile will +push a `paivana`-style template for every existing price category +into the merchant backend (template ID `turnstile-{category_id}`). +You do not need to create those templates by hand. ## Usage -1. Define one or more price categories under `/admin/structure/price-categories`: -1a. Define a price in each currency that you accept -1b. Define a possibly discounted price (including 0) for subscribers -2. Create or edit a node of an enabled content type -3. Set a price category in the respective field to require payment +1. Define one or more price categories under + `/admin/structure/taler-turnstile-price-categories`: + - Define a per-article price in each currency you accept. + - Optionally define a discounted (or zero) price for each + subscription class. A price of exactly `0` for a subscription + marks the category as fully covered by that subscription — + subscribers see the article for free. +2. Create or edit a node of an enabled content type. +3. Pick a price category in the new "Price category" field to gate + the article behind a paywall. 4. Earn money: -4a. Users that need to pay will see truncated content -4b. Users with the cookie will see the original full content + - Visitors without a valid `taler_turnstile_paivana` cookie see + the teaser plus a payment button. + - Once their wallet has paid the merchant template, Turnstile + mints the cookie and they see the full article. +Editing a price category, adding/removing currencies, or changing the +subscription prices automatically refreshes the matching templates in +the merchant backend. Deleting a price category removes its template. -## How it Works -The module uses `hook_entity_view_alter()` to intercept node rendering and -checks if the customer has been granted access. +## How it Works -If they need to pay, we truncate the content body and add a link to -enable the user to pay. If the user did pay (or subscribe), we -display the original content +The paywall is structured around three components: + +1. **Payment templates**, one per price category, kept in the + merchant backend. Templates carry the v1 `choices` array that + describes every accepted (currency, amount, optional subscription + inputs/outputs) tuple. There is **no `website_regex`**: each node + has its own fulfillment URL and templates are matched by category, + not by URL pattern. + +2. **The paywall page** rendered by `hook_entity_view_alter()`. It + replaces the full-mode build with a teaser plus a small + `taler-turnstile-payment-container` block carrying the merchant + URL, template ID, fulfillment URL, and a pre-built + `taler://pay-template/...` URI. The same URI is also advertised + as a `Paivana:` HTTP header for non-JS Taler clients. The page + declares `cookies:taler_turnstile_paivana` as a cache context and + `Vary: Cookie`, so it caches cleanly for everyone without the + cookie (the dominant high-traffic case) while still serving the + full content to visitors who do hold a cookie. + +3. **The confirmation endpoint** at `/taler-turnstile/paivana`. The + client-side JavaScript: + - Generates a 32-byte random `nonce` and computes + `paivana_id = cur_time-base64url(SHA256(nonce || website || cur_time))`. + - Renders `taler://pay-template/HOST/PATH/template_id?session_id={paivana_id}&fulfillment_url={website}` + as a QR code and click-through link. + - Long-polls `{merchant_backend}sessions/{paivana_id}?fulfillment_url=...`. + - Once the merchant reports `paid`, POSTs `{order_id, nonce, cur_time, website}` + back to `/taler-turnstile/paivana`. Turnstile reconstructs the + paivana ID, looks up the order in the merchant backend, and + **verifies that the paid `amount` matches one of the prices + configured for the price category of the node behind `website`**. + Only then does it mint the cookie and 303-redirect. + +Access for non-subscribers is held entirely in the cookie's HMAC +(keyed on Drupal's per-site `private_key` and bound to client IP + +fulfillment URL). Clearing cookies forgets paid access. There is no +database table tracking who paid for what. + +If a contract bought a subscription token, the slug + expiration are +also stored in the (now-started) PHP session so subsequent articles +in the same subscription category short-circuit the paywall without +needing a fresh payment. ## File Structure @@ -74,24 +143,31 @@ taler_turnstile/ │ └── taler_turnstile.schema.yml - configuration schema (partial, without subscription prices) ├── js/ │ ├── qrcode.min.js - QR code library from https://github.com/davidshimjs/qrcodejs -│ └── payment-button.js - shows QR code and long-polls with backend for payment +│ └── payment-button.js - generates paivana_id, polls merchant /sessions, POSTs confirmation ├── src/ +│ ├── Controller/ +│ │ └── PaivanaController.php - handles POST /taler-turnstile/paivana, verifies amount, mints cookie │ ├── Entity/ -│ │ └── TurnstilePriceCategory.php - Main entity class for price categories +│ │ └── TurnstilePriceCategory.php - Price category config entity (also drives template lifecycle) +│ ├── EventSubscriber/ +│ │ └── TurnstileConfigSubscriber.php - Reacts to config saves: field injection + template re-sync │ ├── Form/ │ │ ├── PriceCategoryForm.php - Add/edit form handler │ │ ├── PriceCategoryDeleteForm.php - Delete confirmation form │ │ ├── SubscriptionPricesForm.php - Configure subscription prices │ │ └── TurnstileSettingsForm.php - Configure basics of Turnstile +│ ├── PaivanaCookie.php - HMAC-SHA256 cookie service (keyed on Drupal's private_key) │ ├── PriceCategoryListBuilder.php - Admin list page builder -│ ├── TalerMerchantApiService.php - API service for merchant backend interaction +│ ├── TalerMerchantApiService.php - Backend interaction: templates, order verification │ └── TurnstileFieldManager.php - Manages price-category field injection +├── templates/ +│ └── taler-turnstile-payment-button.html.twig - Paywall block markup + styles ├── taler_turnstile.libraries.yml - JS libraries and dependencies ├── taler_turnstile.info.yml - Module metadata and dependencies ├── taler_turnstile.install - Install/uninstall hooks -├── taler_turnstile.module - Hook implementations and debug functions +├── taler_turnstile.module - Hook implementations ├── taler_turnstile.permissions.yml - Permission definitions -├── taler_turnstile.routing.yml - Route definitions for pages +├── taler_turnstile.routing.yml - Route definitions (admin pages + /taler-turnstile/paivana) ├── taler_turnstile.services.yml - Service container definitions ├── taler_turnstile.links.menu.yml - Menu link to Structure menu ├── taler_turnstile.links.action.yml - Action link for adding price categories @@ -102,7 +178,12 @@ taler_turnstile/ ## Requirements - Drupal 9 or 10 -- PHP 7.4 or higher +- PHP 8.1 or higher (the module uses native enums) +- A GNU Taler merchant backend supporting the v25 (or newer) + `paivana` template type and the public `/sessions/$SESSION_ID` + endpoint +- The Drupal `path_alias` module (used by the confirmation endpoint + to map fulfillment URLs back to nodes) ## License @@ -112,7 +193,8 @@ GPLv2-or-later, see COPYING for the full license terms. ## TODO -- test subscriptions -- test repurchase detection works! -- LATER: use order expiration from merchant backend (with new v1.1 implementation) - instead of hard-coding 1 day! +- test subscriptions end-to-end through the new template flow +- LATER: use order/template expiration from the merchant backend + (with the v1.1 implementation) instead of hard-coding 1 day +- LATER: rate-limit template instantiation on a per-IP basis as + suggested by DD 76 (Solution B) diff --git a/js/payment-button.js b/js/payment-button.js @@ -1,176 +1,175 @@ /** * @file payment-button.js - * @brief JavaScript for GNU Taler Turnstile payment button functionality. - * @author Christian Grothoff + * @brief Paivana-style paywall client for the GNU Taler Turnstile module. + * + * Generates a fresh "paivana_id" client-side, builds a + * taler://pay-template/... URI from the merchant backend + template + * advertised on the page, long-polls the merchant's session endpoint, + * and then asks the Drupal site to mint an access cookie once payment + * was observed. + * * @license LGPLv3+ */ (function ($, Drupal, once) { 'use strict'; - /** - * Long-poll the payment URL to check if payment was completed. - */ - function pollPaymentStatus(paymentUrl, sessionId) { - var separator = paymentUrl.indexOf('?') !== -1 ? '&' : '?'; - const timeout_ms = 30000; - var pollUrl = paymentUrl + separator + 'timeout_ms=' + timeout_ms + '&session_id=' + encodeURIComponent(sessionId); - const start_time = Date.now(); // in milliseconds since Epoch + function waitMs(ms) { + return new Promise(function (resolve) { setTimeout(resolve, ms); }); + } - $.ajax({ - url: pollUrl, - method: 'GET', - timeout: timeout_ms + 5000, // Slightly longer than server timeout - headers: { - 'Accept': 'application/json' - }, - success: function(data, textStatus, xhr) { - // Check if we got 20x (payment completed) - if ( (xhr.status === 200) || (xhr.status === 202) ) { - console.log('Payment completed! Reloading page...'); - window.location.reload(); - } else if (xhr.status === 402) { - console.log('Payment still pending, continuing to poll...'); - const end_time = Date.now(); - // Prevent looping faster than the long-poll timeout - // (useful in case a bug causes the 402 to be returned - // immediately instead of long-polling properly). - const delay = (start_time + timeout_ms > end_time) - ? (start_time + timeout_ms - end_time) - : 0; - setTimeout(function() { - pollPaymentStatus(paymentUrl, sessionId); - }, delay); - } - }, - error: function(xhr, textStatus, errorThrown) { - // Check if this is a 402 Payment Required response - if (xhr.status === 402) { - console.log('Payment still required (402), continuing to poll...'); - pollPaymentStatus(paymentUrl, sessionId); - } else if (textStatus === 'timeout') { - console.log('Poll timeout, retrying...'); - const end_time = Date.now(); - // Prevent looping faster than the long-poll timeout - // (useful in case a bug causes a timeout to be returned - // faster than what we wanted) - const delay = (start_time + timeout_ms > end_time) - ? (start_time + timeout_ms - end_time) - : 0; - setTimeout(function() { - pollPaymentStatus(paymentUrl, sessionId); - }, delay); - } else { - // Other errors - wait a bit before retrying to avoid hammering the server, - // But do not wait the full long-polling period to remain responsive - console.log('Poll error: ' + textStatus + ', retrying in 5 seconds...'); - setTimeout(function() { - pollPaymentStatus(paymentUrl, sessionId); - }, 5000); - } - } + function toBase64Url(bytes) { + var binary = ''; + for (var i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + function sha256b64(str) { + return crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)) + .then(function (buf) { return toBase64Url(new Uint8Array(buf)); }); + } + + function makePaivanaId(curTime, nonce, website) { + return sha256b64(nonce + website + String(curTime)).then(function (hash) { + return curTime + '-' + hash; }); } - /** - * Convert HTTP(S) payment URL to Taler URI format. - * - * @param {string} paymentUrl - The HTTP(S) payment URL - * @param {string} sessionId - The hashed session ID - * @returns {string} The Taler 'pay' URI including session ID - */ - function convertToTalerUri(paymentUrl, sessionId) { - try { - var url = new URL(paymentUrl); - var protocol = url.protocol; // 'https:' or 'http:' - var host = url.host; // includes port if specified - var pathname = url.pathname; // e.g., '/something/orders/12345' + function buildTalerUri(paivanaUri, paivanaId, fulfillmentUrl) { + var sep = paivanaUri.indexOf('?') === -1 ? '?' : '&'; + return paivanaUri + sep + + 'session_id=' + encodeURIComponent(paivanaId) + + '&fulfillment_url=' + encodeURIComponent(fulfillmentUrl); + } - // Extract the path components, removing '/orders/' part - // Expected input: [/instance/$ID]/orders/$ORDER_ID - // Expected output: [/instance/$ID]/$ORDER_ID - var pathParts = pathname.split('/').filter(function(part) { - return part.length > 0; - }); + function setStatus($container, msg) { + $container.find('.taler-turnstile-status-message').text(msg); + } - // Find 'orders' in the path and reconstruct without it - var ordersIndex = pathParts.indexOf('orders'); - var talerPath = ''; + function startFlow(container) { + var $container = $(container); + var merchantBackend = $container.data('merchant-backend'); + var templateId = $container.data('template-id'); + var fulfillmentUrl = $container.data('fulfillment-url'); + var confirmUrl = $container.data('confirm-url'); + var paivanaUri = $container.data('paivana-uri'); + var maxPickupDelay = parseInt($container.data('max-pickup-delay'), 10) || 86400; + if (!merchantBackend || !templateId || !confirmUrl || !paivanaUri) { + console.error('[turnstile] required data attributes missing on payment container', $container[0]); + return; + } + // Cap the pickup window so we don't overflow time math, but + // otherwise honor what the server template advertised. + var pickup = Math.min(maxPickupDelay, 60 * 60 * 24 * 365 * 100); + var curTime = Math.floor(Date.now() / 1000) + pickup; + var nonceArr = new Uint8Array(32); + crypto.getRandomValues(nonceArr); + var nonce = Array.from(nonceArr) + .map(function (b) { return b.toString(16).padStart(2, '0'); }) + .join(''); - if (ordersIndex !== -1 && ordersIndex < pathParts.length - 1) { - // Get parts before 'orders' and after 'orders' - var beforeOrders = pathParts.slice(0, ordersIndex); - var afterOrders = pathParts.slice(ordersIndex + 1); - talerPath = beforeOrders.concat(afterOrders).join('/'); - } else { - console.error('Error converting to Taler URI: "/orders/" not found'); - return paymentUrl; - } + makePaivanaId(curTime, nonce, fulfillmentUrl).then(function (paivanaId) { + var talerUri = buildTalerUri(paivanaUri, paivanaId, fulfillmentUrl); - if (protocol === 'https:') { - return 'taler://pay/' + host + '/' + talerPath + - '/' + encodeURIComponent(sessionId); - } else if (protocol === 'http:') { - return 'taler+http://pay/' + - host + '/' + talerPath + - '/' + encodeURIComponent(sessionId); + // QR + click-through link. + var $qr = $container.find('.taler-turnstile-qr-code-container').first(); + $qr.empty(); + if (typeof QRCode !== 'undefined') { + new QRCode($qr[0], { + text: talerUri, + width: 200, + height: 200, + colorDark: '#000000', + colorLight: '#ffffff', + correctLevel: QRCode.CorrectLevel.M + }); } + $container.find('.taler-turnstile-pay-button').attr('href', talerUri); - console.error('Error converting to Taler URI: unsupported protocol'); - return paymentUrl; - } catch (e) { - console.error('Error converting to Taler URI:', e); - return paymentUrl; - } + pollSession({ + $container: $container, + merchantBackend: merchantBackend, + paivanaId: paivanaId, + nonce: nonce, + curTime: curTime, + fulfillmentUrl: fulfillmentUrl, + confirmUrl: confirmUrl + }); + }).catch(function (e) { + console.error('[turnstile] failed to compute paivana id:', e); + setStatus($container, Drupal.t('Could not initialize payment.')); + }); + } + + function pollSession(ctx) { + var pollUrl = ctx.merchantBackend.replace(/\/$/, '') + + '/sessions/' + encodeURIComponent(ctx.paivanaId) + + '?timeout_ms=30000' + + '&fulfillment_url=' + encodeURIComponent(ctx.fulfillmentUrl); + var start = performance.now(); + fetch(pollUrl, { cache: 'no-store' }) + .then(function (res) { + if (res.status === 200) { + return res.json().then(function (j) { + return confirmPayment(ctx, j.order_id); + }); + } + // 202 = unpaid (long poll returned), 404 = no order yet, + // anything else is treated as transient. + var rem = 30000 - (performance.now() - start); + if (rem < 0) rem = 0; + return waitMs(rem).then(function () { pollSession(ctx); }); + }) + .catch(function (e) { + console.warn('[turnstile] poll error:', e); + setStatus(ctx.$container, Drupal.t('Network error. Retrying...')); + waitMs(5000).then(function () { pollSession(ctx); }); + }); } + function confirmPayment(ctx, orderId) { + setStatus(ctx.$container, Drupal.t('Payment confirmed! Loading page...')); + return fetch(ctx.confirmUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + order_id: orderId, + nonce: ctx.nonce, + cur_time: ctx.curTime, + website: ctx.fulfillmentUrl + }) + }).then(function (res) { + // Server responds 303 with the cookie + Location; fetch follows + // redirects transparently, so res.url is the final destination. + var dest = (res.redirected && res.url) ? res.url : ctx.fulfillmentUrl; + window.location.href = dest; + }).catch(function (e) { + console.warn('[turnstile] confirm error:', e); + setStatus(ctx.$container, Drupal.t('Could not reach the server.')); + }); + } - /** - * Attach payment button behavior. - */ Drupal.behaviors.talerTurnstilePaymentButton = { attach: function (context, settings) { - // Do taler presence detection exactly once. - once('taler-support', 'html').forEach(() => { - // Detect presence of taler support in the browser. - window.talerCallback = (res) => { - console.log("talerCallback", res); - if (res.present) { - const els = $('.show-if-taler-supported'); - els.removeClass("hidden"); + // Detect taler:// support exactly once. + once('taler-support', 'html').forEach(function () { + window.talerCallback = function (res) { + if (res && res.present) { + $('.show-if-taler-supported').removeClass('hidden'); } else { - $('.show-if-taler-supported').addClass("hidden"); + $('.show-if-taler-supported').addClass('hidden'); } }; - // Add taler-support meta tag - let meta = document.createElement('meta'); - meta.name = "taler-support"; - meta.content = "api,callback"; + var meta = document.createElement('meta'); + meta.name = 'taler-support'; + meta.content = 'api,callback'; document.getElementsByTagName('head')[0].appendChild(meta); }); - var qrContainers = once('taler-turnstile-qr-generation', '.taler-turnstile-qr-code-container', context); - qrContainers.forEach(function(qrContainer) { - var $qrContainer = $(qrContainer); - var paymentUrl = $qrContainer.data('payment-url'); - var sessionId = $qrContainer.data('session-id'); - if (paymentUrl && typeof QRCode !== 'undefined') { - $qrContainer.empty(); - var talerUri = convertToTalerUri(paymentUrl, sessionId); - new QRCode($qrContainer[0], { - text: talerUri, - width: 200, - height: 200, - colorDark: '#000000', - colorLight: '#ffffff', - correctLevel: QRCode.CorrectLevel.M - }); - } - - if (paymentUrl) { - console.log('Starting payment status polling for: ' + paymentUrl); - pollPaymentStatus(paymentUrl, sessionId); - } - }); + var containers = once('taler-turnstile-payment-init', '.taler-turnstile-payment-container', context); + containers.forEach(function (c) { startFlow(c); }); } }; diff --git a/src/Controller/PaivanaController.php b/src/Controller/PaivanaController.php @@ -0,0 +1,166 @@ +<?php + +/** + * @file + * Location: src/Controller/PaivanaController.php + * + * Confirms a paid Paivana-style purchase, validates the contract + * matches a known fulfillment URL and price category, and on success + * mints the access cookie and redirects. + */ + +namespace Drupal\taler_turnstile\Controller; + +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Url; +use Drupal\node\NodeInterface; +use Drupal\path_alias\AliasManagerInterface; +use Drupal\taler_turnstile\Entity\TurnstilePriceCategory; +use Drupal\taler_turnstile\PaivanaCookie; +use Drupal\taler_turnstile\TalerMerchantApiService; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; + +/** + * Controller backing /taler-turnstile/paivana. + */ +class PaivanaController extends ControllerBase { + + /** + * @var \Drupal\taler_turnstile\TalerMerchantApiService + */ + protected $apiService; + + /** + * @var \Drupal\taler_turnstile\PaivanaCookie + */ + protected $cookies; + + /** + * @var \Drupal\path_alias\AliasManagerInterface + */ + protected $aliasManager; + + public function __construct(TalerMerchantApiService $api_service, + PaivanaCookie $cookies, + AliasManagerInterface $alias_manager) { + $this->apiService = $api_service; + $this->cookies = $cookies; + $this->aliasManager = $alias_manager; + } + + public static function create(ContainerInterface $container) { + return new static( + $container->get('taler_turnstile.api_service'), + $container->get('taler_turnstile.cookie'), + $container->get('path_alias.manager') + ); + } + + + /** + * Reverse $url to the node it canonically points at, so we can + * load its price category. Returns NULL if no node could be + * resolved. + */ + protected function nodeFromUrl(string $url): ?NodeInterface { + $base = $this->getRequest()->getSchemeAndHttpHost(); + if (strpos($url, $base) !== 0) { + // The fulfillment URL must live on this site, otherwise we + // have nothing meaningful to validate against. + return NULL; + } + $path = parse_url($url, PHP_URL_PATH) ?: '/'; + // Resolve any path alias (e.g. "/articles/foo") down to "/node/123". + $internal = $this->aliasManager->getPathByAlias($path); + if (! preg_match('#^/node/(\d+)$#', $internal, $m)) { + return NULL; + } + $node = $this->entityTypeManager()->getStorage('node')->load($m[1]); + return $node instanceof NodeInterface ? $node : NULL; + } + + + /** + * POST handler. Body: {order_id, nonce, cur_time, website}. + * On success, returns 303 + Set-Cookie + Location: $website. + */ + public function confirm(Request $request) { + $payload = json_decode((string) $request->getContent(), TRUE); + if (! is_array($payload)) { + return new JsonResponse(['error' => 'malformed_json'], 400); + } + $order_id = $payload['order_id'] ?? NULL; + $nonce = $payload['nonce'] ?? NULL; + $cur_time = $payload['cur_time'] ?? NULL; + $website = $payload['website'] ?? NULL; + if (! is_string($order_id) || ! is_string($nonce) || + ! is_int($cur_time) || ! is_string($website)) { + return new JsonResponse(['error' => 'missing_or_malformed_fields'], 400); + } + if ($cur_time <= time()) { + return new JsonResponse(['error' => 'expired'], 410); + } + + // Reconstruct the paivana ID exactly the same way the JS did. + $hash = hash('sha256', $nonce . $website . (string) $cur_time, TRUE); + $paivana_id = $cur_time . '-' . rtrim(strtr(base64_encode($hash), '+/', '-_'), '='); + + // Resolve the website to a Drupal node so we can determine the + // price category and thus the set of acceptable amounts. + $node = $this->nodeFromUrl($website); + if (! $node) { + \Drupal::logger('taler_turnstile')->warning('Confirm: cannot resolve fulfillment URL @url to a node', ['@url' => $website]); + return new JsonResponse(['error' => 'unknown_fulfillment_url'], 404); + } + if (! $node->hasField('field_taler_turnstile_prcat') || + $node->get('field_taler_turnstile_prcat')->isEmpty()) { + return new JsonResponse(['error' => 'no_price_category'], 404); + } + /** @var TurnstilePriceCategory $price_category */ + $price_category = $node->get('field_taler_turnstile_prcat')->entity; + if (! $price_category) { + return new JsonResponse(['error' => 'no_price_category'], 404); + } + $expected_amounts = $price_category->getAcceptableAmounts( + $this->apiService->getSubscriptions() + ); + if (empty($expected_amounts)) { + return new JsonResponse(['error' => 'no_choices_configured'], 500); + } + + $verified = $this->apiService->verifyPaidOrder( + $order_id, + $paivana_id, + $website, + $expected_amounts + ); + if (! $verified) { + return new JsonResponse(['error' => 'order_not_validated'], 402); + } + + // If the contract bought a subscription, persist that in the + // session so subsequent visits can short-circuit even when + // the per-URL cookie expires. + if (! empty($verified['subscription_slug'])) { + _taler_turnstile_grant_subscriber_access( + $verified['subscription_slug'], + (int) $verified['subscription_expiration'] + ); + } + + $cookie = $this->cookies->mint( + $request, + $website, + TalerMerchantApiService::ORDER_VALIDITY_SECONDS + ); + $response = new RedirectResponse($website, 303); + $response->headers->setCookie($cookie); + // The result is per-cookie and short-lived; do not cache. + $response->headers->set('Cache-Control', 'no-store'); + return $response; + } + +} diff --git a/src/Entity/TurnstilePriceCategory.php b/src/Entity/TurnstilePriceCategory.php @@ -8,6 +8,7 @@ namespace Drupal\taler_turnstile\Entity; use Drupal\Core\Config\Entity\ConfigEntityBase; +use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; /** @@ -91,6 +92,40 @@ class TurnstilePriceCategory extends ConfigEntityBase { } /** + * Compute the merchant template ID used to publish the prices + * of this category in the merchant backend. Kept short and + * reasonably distinctive (per the README, "turnstile-$NAME"). + * + * @return string + * Template ID + */ + public function getTemplateId(): string { + return 'turnstile-' . $this->id(); + } + + + /** + * Return the set of "currency:amount" strings that match valid + * payment amounts for this category. Used to verify that a paid + * contract actually paid for the right thing. + * + * @param array $subscriptions + * Live subscription data, as produced by + * TalerMerchantApiService::getSubscriptions(). + * @return array<string> + */ + public function getAcceptableAmounts(array $subscriptions): array { + $amounts = []; + foreach ($this->getPaymentChoices($subscriptions) as $choice) { + if (isset($choice['amount'])) { + $amounts[] = $choice['amount']; + } + } + return $amounts; + } + + + /** * Gets a brief hint to display about non-subscriber prices. * * @return string @@ -301,6 +336,37 @@ class TurnstilePriceCategory extends ConfigEntityBase { /** + * {@inheritdoc} + * + * Mirror this category as a "paivana"-style template in the + * merchant backend on every save (create or update). + */ + public function postSave(EntityStorageInterface $storage, $update = TRUE) { + parent::postSave($storage, $update); + /** @var \Drupal\taler_turnstile\TalerMerchantApiService $api */ + $api = \Drupal::service('taler_turnstile.api_service'); + $api->syncTemplate($this); + } + + + /** + * {@inheritdoc} + * + * Remove the matching template from the merchant backend when + * this category goes away. + */ + public static function postDelete(EntityStorageInterface $storage, array $entities) { + parent::postDelete($storage, $entities); + /** @var \Drupal\taler_turnstile\TalerMerchantApiService $api */ + $api = \Drupal::service('taler_turnstile.api_service'); + foreach ($entities as $entity) { + /** @var TurnstilePriceCategory $entity */ + $api->deleteTemplate($entity->getTemplateId()); + } + } + + + /** * Build a translation map for all enabled languages. * * @param string $string diff --git a/src/EventSubscriber/TurnstileConfigSubscriber.php b/src/EventSubscriber/TurnstileConfigSubscriber.php @@ -16,6 +16,7 @@ use Drupal\Core\Config\ConfigCrudEvent; use Drupal\Core\Config\ConfigEvents; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\taler_turnstile\TurnstileFieldManager; +use Drupal\taler_turnstile\TalerMerchantApiService; use Drupal\Core\Messenger\MessengerInterface; class TurnstileConfigSubscriber implements EventSubscriberInterface { @@ -46,6 +47,13 @@ class TurnstileConfigSubscriber implements EventSubscriberInterface { protected $messenger; /** + * The Turnstile API service. + * + * @var \Drupal\taler_turnstile\TalerMerchantApiService + */ + protected $apiService; + + /** * Constructs a TurnstileSettingsForm object. * * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation @@ -56,15 +64,19 @@ class TurnstileConfigSubscriber implements EventSubscriberInterface { * The entity type manager. * @param \Drupal\taler_turnstile\TurnstileFieldManager $field_manager * The field manager. + * @param \Drupal\taler_turnstile\TalerMerchantApiService $api_service + * The Turnstile API service. */ public function __construct(TranslationInterface $string_translation, MessengerInterface $messenger, EntityTypeManagerInterface $entity_type_manager, - TurnstileFieldManager $field_manager) { + TurnstileFieldManager $field_manager, + TalerMerchantApiService $api_service) { $this->stringTranslation = $string_translation; $this->messenger = $messenger; $this->entityTypeManager = $entity_type_manager; $this->fieldManager = $field_manager; + $this->apiService = $api_service; } /** @@ -118,6 +130,19 @@ class TurnstileConfigSubscriber implements EventSubscriberInterface { '@new' => json_encode($new_enabled_types), ]); } + // Subscription prices feed every category's "buy subscription" + // choices, so keep all merchant templates in sync when they change. + if ($event->isChanged('subscription_prices')) { + $this->apiService->syncAllTemplates(); + } + // When the operator (re)configures the merchant backend or + // rotates the access token, the new instance will not yet have + // any of our templates — push them all so the paywall keeps + // working without manual intervention. + if ($event->isChanged('payment_backend_url') || + $event->isChanged('access_token')) { + $this->apiService->syncAllTemplates(); + } } /** diff --git a/src/PaivanaCookie.php b/src/PaivanaCookie.php @@ -0,0 +1,107 @@ +<?php + +/** + * @file + * Helper for the Turnstile Paivana access cookie. + */ + +namespace Drupal\taler_turnstile; + +use Drupal\Core\PrivateKey; +use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpFoundation\Request; + +/** + * Mints and verifies the cookie that grants access to a previously + * paid-for fulfillment URL. The cookie is bound to the visitor's IP + * address and the fulfillment URL, and carries its own expiration in + * its prefix: + * + * value := <cur_time>-<base64url(HMAC-SHA256(secret, cur_time||url||ip))> + * + * Mirrors the paivana-httpd implementation but uses HMAC-SHA256 with + * Drupal's private_key as keying material so we do not need a new + * config storage for a server-side secret. + */ +class PaivanaCookie { + + /** + * Cookie name; must match the constant exported from the .module file. + */ + const COOKIE_NAME = 'taler_turnstile_paivana'; + + /** + * @var \Drupal\Core\PrivateKey + */ + protected $privateKey; + + public function __construct(PrivateKey $private_key) { + $this->privateKey = $private_key; + } + + + /** + * Compute the keyed hash for ($cur_time, $website, $client_addr). + * Returned as URL-safe base64 without padding. + */ + protected function hash(int $cur_time, string $website, string $client_addr): string { + $msg = pack('NN', 0, $cur_time) . $website . "\0" . $client_addr; + $raw = hash_hmac('sha256', $msg, $this->privateKey->get(), TRUE); + return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + } + + + /** + * Build a cookie carrying access for $website, valid until + * now + $lifetime_seconds. Cookie is bound to the request's + * client address. + * + * @return \Symfony\Component\HttpFoundation\Cookie + */ + public function mint(Request $request, string $website, int $lifetime_seconds): Cookie { + $expires = time() + $lifetime_seconds; + $client_addr = $request->getClientIp() ?? ''; + $value = $expires . '-' . $this->hash($expires, $website, $client_addr); + return Cookie::create( + self::COOKIE_NAME, + $value, + $expires, + '/', + NULL, + $request->isSecure(), + TRUE, + FALSE, + Cookie::SAMESITE_LAX + ); + } + + + /** + * Check whether the request carries a valid Turnstile cookie for + * $website. Returns TRUE only if the cookie's MAC matches and its + * timestamp is still in the future. + */ + public function verify(Request $request, string $website): bool { + $value = $request->cookies->get(self::COOKIE_NAME); + if (! is_string($value) || $value === '') { + return FALSE; + } + $dash = strpos($value, '-'); + if ($dash === FALSE) { + return FALSE; + } + $ts_part = substr($value, 0, $dash); + $mac_part = substr($value, $dash + 1); + if (! ctype_digit($ts_part)) { + return FALSE; + } + $ts = (int) $ts_part; + if ($ts <= time()) { + return FALSE; + } + $client_addr = $request->getClientIp() ?? ''; + $expected = $this->hash($ts, $website, $client_addr); + return hash_equals($expected, $mac_part); + } + +} diff --git a/src/TalerMerchantApiService.php b/src/TalerMerchantApiService.php @@ -10,7 +10,6 @@ namespace Drupal\taler_turnstile; use Drupal\Core\Http\ClientFactory; -use Drupal\node\NodeInterface; use Psr\Log\LoggerInterface; use Drupal\taler_turnstile\Entity\TurnstilePriceCategory; use GuzzleHttp\Exception\RequestException; @@ -26,6 +25,8 @@ enum TalerErrorCode: int { case TALER_EC_NONE = 0; case TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN = 2000; case TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN = 2005; + case TALER_EC_MERCHANT_GENERIC_TEMPLATE_UNKNOWN = 2030; + case TALER_EC_MERCHANT_PRIVATE_POST_TEMPLATES_CONFLICT_TEMPLATE_EXISTS = 2603; } @@ -359,7 +360,8 @@ class TalerMerchantApiService { /** - * Check order status with Taler backend. + * Check order status with Taler backend. Used only for diagnostic + * code paths; the main paywall flow uses verifyPaidOrder(). * * @param string $order_id * The order ID to check. @@ -524,137 +526,358 @@ class TalerMerchantApiService { /** - * Create a new Taler order. - * - * @param \Drupal\node\NodeInterface $node - * The node to create an order for. + * Build the request body for a "paivana"-style template + * mirroring the prices of the given $price_category. * + * @param TurnstilePriceCategory $price_category + * The price category to mirror. * @return array|FALSE - * Order information or FALSE on failure. + * Body suitable for POST/PATCH on /private/templates, + * or FALSE if the category does not yield a usable set + * of payment choices. + */ + private function buildTemplateBody(TurnstilePriceCategory $price_category) { + $subscriptions = $this->getSubscriptions(); + $choices = $price_category->getPaymentChoices($subscriptions); + if (empty($choices)) { + return FALSE; + } + $description = $price_category->getDescription(); + if (empty($description)) { + $description = $price_category->label() ?? $price_category->id(); + } + return [ + 'template_id' => $price_category->getTemplateId(), + 'template_description' => $description, + 'template_contract' => [ + 'template_type' => 'paivana', + 'summary' => 'Access to: @' . $price_category->id(), + 'choices' => $choices, + // Limit how long a paywall page is valid; the cookie + // we hand out cannot outlive the order. + 'pay_duration' => [ 'd_us' => self::ORDER_VALIDITY_SECONDS * 1000000 ], + 'max_pickup_duration' => [ 'd_us' => self::ORDER_VALIDITY_SECONDS * 1000000 ], + ], + ]; + } + + + /** + * Create or update the "paivana"-style template in the merchant + * backend that mirrors the prices configured in $price_category. + * Performs a POST and falls back to PATCH if the template already + * exists. + * + * @param TurnstilePriceCategory $price_category + * The price category to publish as a template. + * @return bool + * TRUE on success, FALSE on any error */ - public function createOrder(NodeInterface $node) { + public function syncTemplate(TurnstilePriceCategory $price_category): bool { $config = \Drupal::config('taler_turnstile.settings'); $backend_url = $config->get('payment_backend_url'); $access_token = $config->get('access_token'); - if (empty($backend_url) || empty($access_token)) { - $this->logger->debug('No backend, cannot setup new order'); + $this->logger->debug('No backend, skipping template sync for @id', ['@id' => $price_category->id()]); return FALSE; } + $body = $this->buildTemplateBody($price_category); + if (FALSE === $body) { + $this->logger->info('Price category @id has no usable choices, deleting any existing template', ['@id' => $price_category->id()]); + return $this->deleteTemplate($price_category->getTemplateId()); + } + + $template_id = $body['template_id']; + $http_client = $this->httpClientFactory->fromOptions([ + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + 'Content-Type' => 'application/json', + ], + 'http_errors' => FALSE, + 'allow_redirects' => TRUE, + 'timeout' => 5, + ]); + try { + $response = $http_client->post($backend_url . 'private/templates', [ + 'json' => $body, + ]); + $http_status = $response->getStatusCode(); + if ($http_status === 204) { + $this->logger->info('Created template @tid', ['@tid' => $template_id]); + return TRUE; + } + $jbody = json_decode((string) $response->getBody(), TRUE) ?? []; + $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($http_status === 409) { + // Template already exists, fall through to PATCH below. + $this->logger->debug('Template @tid already exists, updating via PATCH', ['@tid' => $template_id]); + } + else { + $this->logger->error('Unexpected HTTP status @status creating template @tid: @hint (@detail, #@ec): @body', [ + '@status' => $http_status, + '@tid' => $template_id, + '@hint' => $jbody['hint'] ?? 'N/A', + '@detail' => $jbody['detail'] ?? 'N/A', + '@ec' => $jbody['code'] ?? 'N/A', + '@body' => $body_log_fmt ?? 'N/A', + ]); + if ($http_status !== 409) { + return FALSE; + } + } - /** @var \Drupal\Core\Field\EntityReferenceFieldItemList $field */ - $field = $node->get('field_taler_turnstile_prcat'); - if ($field->isEmpty()) { - $this->logger->debug('No price category selected'); + // PATCH path. Note that PATCH does NOT take template_id in the body. + $patch_body = $body; + unset($patch_body['template_id']); + $response = $http_client->patch( + $backend_url . 'private/templates/' . rawurlencode($template_id), + ['json' => $patch_body] + ); + $http_status = $response->getStatusCode(); + if ($http_status === 204) { + $this->logger->info('Updated template @tid', ['@tid' => $template_id]); + return TRUE; + } + $jbody = json_decode((string) $response->getBody(), TRUE) ?? []; + $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $this->logger->error('Unexpected HTTP status @status updating template @tid: @hint (@detail, #@ec): @body', [ + '@status' => $http_status, + '@tid' => $template_id, + '@hint' => $jbody['hint'] ?? 'N/A', + '@detail' => $jbody['detail'] ?? 'N/A', + '@ec' => $jbody['code'] ?? 'N/A', + '@body' => $body_log_fmt ?? 'N/A', + ]); + return FALSE; + } + catch (RequestException $e) { + $this->logger->error('Failed to sync template @tid: @message', [ + '@tid' => $template_id, + '@message' => $e->getMessage(), + ]); return FALSE; } + } + - /** @var TurnstilePriceCategory $price_category */ - $price_category = $field->entity; - if (! $price_category) { - $this->logger->debug('No price category, cannot setup new order'); + /** + * Delete the template with the given ID in the merchant backend. + * A 404 from the backend is treated as success, since the desired + * end state is "no such template". + * + * @param string $template_id + * The full template ID to delete. + * @return bool + * TRUE on success or 404, FALSE on any other error + */ + public function deleteTemplate(string $template_id): bool { + $config = \Drupal::config('taler_turnstile.settings'); + $backend_url = $config->get('payment_backend_url'); + $access_token = $config->get('access_token'); + if (empty($backend_url) || empty($access_token)) { + $this->logger->debug('No backend, skipping template delete for @tid', ['@tid' => $template_id]); return FALSE; } - $subscriptions = $this->getSubscriptions(); - $choices = $price_category->getPaymentChoices($subscriptions); - if (empty($choices)) { - $this->logger->debug('Price list is empty, cannot setup new order'); + try { + $http_client = $this->httpClientFactory->fromOptions([ + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + ], + 'http_errors' => FALSE, + 'allow_redirects' => TRUE, + 'timeout' => 5, + ]); + $response = $http_client->delete( + $backend_url . 'private/templates/' . rawurlencode($template_id) + ); + $http_status = $response->getStatusCode(); + if ($http_status === 204 || $http_status === 404) { + $this->logger->info('Template @tid removed (HTTP @status)', ['@tid' => $template_id, '@status' => $http_status]); + return TRUE; + } + $jbody = json_decode((string) $response->getBody(), TRUE) ?? []; + $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $this->logger->error('Unexpected HTTP status @status deleting template @tid: @hint (@detail, #@ec): @body', [ + '@status' => $http_status, + '@tid' => $template_id, + '@hint' => $jbody['hint'] ?? 'N/A', + '@detail' => $jbody['detail'] ?? 'N/A', + '@ec' => $jbody['code'] ?? 'N/A', + '@body' => $body_log_fmt ?? 'N/A', + ]); + return FALSE; + } + catch (RequestException $e) { + $this->logger->error('Failed to delete template @tid: @message', [ + '@tid' => $template_id, + '@message' => $e->getMessage(), + ]); return FALSE; } + } - $fulfillment_url = $node->toUrl('canonical', ['absolute' => TRUE])->toString(); - // Get (hashed) session ID - $hashed_session_id = $this->getHashedSessionId(); - $this->logger->debug('Taler session is @session', ['@session' => $hashed_session_id]); + /** + * Re-publish all known TurnstilePriceCategory templates. + * Useful after settings changes that affect the contents + * of every category template (e.g. subscription_prices). + */ + public function syncAllTemplates(): void { + try { + $categories = \Drupal::entityTypeManager() + ->getStorage('taler_turnstile_price_category') + ->loadMultiple(); + } + catch (\Exception $e) { + $this->logger->error('Failed to load price categories: @message', ['@message' => $e->getMessage()]); + return; + } + foreach ($categories as $category) { + $this->syncTemplate($category); + } + } - // FIXME: after Merchant v1.1 we can use the returned - // the expiration time and then rely on the default already set in - // the merchant backend instead of hard-coding 1 day here! - $order_expiration = time() + self::ORDER_VALIDITY_SECONDS; - $order_data = [ - 'order' => [ - 'version' => 1, - 'choices' => $choices, - 'summary' => 'Access to: ' . $node->getTitle(), - 'summary_i18n' => $this->buildTranslationMap ('Access to: @title', - ['@title' => $node->getTitle()]), - 'fulfillment_url' => $fulfillment_url, - 'pay_deadline' => [ - 't_s' => $order_expiration, - ], - ], - 'session_id' => $hashed_session_id, - 'create_token' => FALSE, - ]; - $jbody = []; + /** + * Look up a paid order with the given session ID and verify that + * it actually pays for the given $website (fulfillment URL) at + * one of the prices listed in @a expected_amounts. + * + * @param string $order_id + * The order ID claimed by the client. + * @param string $session_id + * The session ID (paivana_id) the client computed. + * @param string $website + * The fulfillment URL that the contract must reference. + * @param array $expected_amounts + * Whitelist of acceptable "currency:amount" strings, typically + * built from the price category for the node. + * @return array|FALSE + * On success: ['paid' => TRUE, 'subscription_slug' => ?, + * 'subscription_expiration' => ?]. + * FALSE on any failure (also logs). + */ + public function verifyPaidOrder(string $order_id, + string $session_id, + string $website, + array $expected_amounts) { + $config = \Drupal::config('taler_turnstile.settings'); + $backend_url = $config->get('payment_backend_url'); + $access_token = $config->get('access_token'); + if (empty($backend_url) || empty($access_token)) { + $this->logger->debug('No backend configured, cannot verify order'); + return FALSE; + } try { - $http_client = $this->httpClientFactory->fromOptions ([ + $http_client = $this->httpClientFactory->fromOptions([ 'headers' => [ 'Authorization' => 'Bearer ' . $access_token, - 'Content-Type' => 'application/json', ], - // Do not throw exceptions on 4xx/5xx status codes - 'http_errors' => false, + 'http_errors' => FALSE, 'allow_redirects' => TRUE, - 'timeout' => 5, // seconds + 'timeout' => 5, ]); - $response = $http_client->post($backend_url . 'private/orders', [ - 'json' => $order_data, - ]); - // Get JSON result parsed as associative array + $url = $backend_url . 'private/orders/' . rawurlencode($order_id) + . '?session_id=' . rawurlencode($session_id); + $response = $http_client->get($url); $http_status = $response->getStatusCode(); - $body = $response->getBody(); - $jbody = json_decode($body, TRUE); - switch ($http_status) - { - case 200: - if (! isset($jbody['order_id'])) { - $this->logger->error('Failed to create order: HTTP success response unexpectedly lacks "order_id" field.'); - return FALSE; + $jbody = json_decode((string) $response->getBody(), TRUE) ?? []; + if ($http_status !== 200) { + $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $this->logger->warning('Unexpected HTTP status @status verifying order @oid: @hint (@detail, #@ec): @body', [ + '@status' => $http_status, + '@oid' => $order_id, + '@hint' => $jbody['hint'] ?? 'N/A', + '@detail' => $jbody['detail'] ?? 'N/A', + '@ec' => $jbody['code'] ?? 'N/A', + '@body' => $body_log_fmt ?? 'N/A', + ]); + return FALSE; + } + if (($jbody['order_status'] ?? '') !== 'paid') { + $this->logger->info('Order @oid not (yet) paid, status=@s', [ + '@oid' => $order_id, + '@s' => $jbody['order_status'] ?? 'unknown', + ]); + return FALSE; + } + $contract_terms = $jbody['contract_terms'] ?? []; + $contract_fulfillment = $contract_terms['fulfillment_url'] ?? ''; + if ($contract_fulfillment !== $website) { + $this->logger->warning('Paid order @oid is for fulfillment URL "@got" but client claimed "@want"', [ + '@oid' => $order_id, + '@got' => $contract_fulfillment, + '@want' => $website, + ]); + return FALSE; + } + // Pull out the paid amount. Contract version 1 stores choices. + $contract_version = $jbody['version'] ?? 0; + $paid_amount = NULL; + $subscription_slug = FALSE; + $subscription_expiration = 0; + if (1 === $contract_version) { + $choice_index = $jbody['choice_index'] ?? 0; + $contract_choice = $contract_terms['choices'][$choice_index] ?? []; + $paid_amount = $contract_choice['amount'] ?? NULL; + // Detect any subscription tokens generated by this purchase. + $token_families = $contract_terms['token_families'] ?? []; + $outputs = $contract_choice['outputs'] ?? []; + $now = time(); + foreach ($outputs as $output) { + $slug = $output['token_family_slug'] ?? NULL; + if (!$slug || !isset($token_families[$slug])) { + continue; } - // Success, handled below - break; - case 403: - $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your GNU Taler Turnstile configuration!'); - return FALSE; - case 404: - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Failed to create order: @hint (@ec): @body', ['@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']); - return FALSE; - case 409: - case 410: - // 409: We didn't specify an order, so this should be "wrong currency", which again GNU Taler Turnstile tries to prevent. So this shouldn't be possible. - // 410: We didn't specify a product, so out-of-stock should also be impossible for GNU Taler Turnstile - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Unexpected HTTP status code @status trying to create order: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@detail' => $jbody['detail'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']); - return FALSE; - case 451: - // KYC required, can happen, warn - $this->logger->warning('Failed to create order as legitimization is required first. Please check legitimization status in your merchant backend.'); - return FALSE; - default: - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Unexpected HTTP status code @status trying to create order: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@detail' => $jbody['detail'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']); - return FALSE; - } // end switch on HTTP status - - $order_id = $jbody['order_id']; + $token_family = $token_families[$slug]; + if (($token_family['details']['class'] ?? NULL) !== 'subscription') { + continue; + } + foreach ($token_family['keys'] ?? [] as $key) { + $start = $key['signature_validity_start']['t_s'] ?? 0; + $end = $key['signature_validity_end']['t_s'] ?? 0; + if (($start <= $now) && ($end > $now)) { + $subscription_slug = $slug; + $subscription_expiration = $end; + break 2; + } + } + } + } + else { + $this->logger->error('Unsupported contract version @v for order @oid', [ + '@v' => $contract_version, + '@oid' => $order_id, + ]); + return FALSE; + } + if ($paid_amount === NULL) { + $this->logger->error('Could not determine paid amount for order @oid', ['@oid' => $order_id]); + return FALSE; + } + if (!in_array($paid_amount, $expected_amounts, TRUE)) { + $this->logger->warning('Paid order @oid has amount @got which is not among acceptable amounts (@want) for fulfillment @url', [ + '@oid' => $order_id, + '@got' => $paid_amount, + '@want' => implode(', ', $expected_amounts), + '@url' => $website, + ]); + return FALSE; + } return [ - 'order_id' => $order_id, - 'payment_url' => $backend_url . 'orders/' . $order_id, - 'order_expiration' => $order_expiration, - 'paid' => FALSE, - 'session_id' => $hashed_session_id, + 'paid' => TRUE, + 'amount' => $paid_amount, + 'subscription_slug' => $subscription_slug, + 'subscription_expiration' => $subscription_expiration, ]; } catch (RequestException $e) { - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Failed to create Taler order: @message: @body', ['@message' => $e->getMessage(), '@body' => $body_log_fmt ?? 'N/A']); + $this->logger->error('Failed to verify order @oid: @message', [ + '@oid' => $order_id, + '@message' => $e->getMessage(), + ]); + return FALSE; } - - return FALSE; } @@ -683,30 +906,4 @@ class TalerMerchantApiService { } - /** - * Generate a hashed session identifier for payment tracking. - * - * This creates a deterministic hash from the PHP session ID that can be - * safely shared with the client and merchant backend as the - * Taler "session_id". - * - * @return string - * Base64-encoded SHA-256 hash of the session ID (URL-safe). - */ - private function getHashedSessionId(): string { - $raw_session_id = session_id(); - if (empty($raw_session_id)) { - // If no session exists, start one - if (session_status() === PHP_SESSION_NONE) { - session_start(); - $raw_session_id = session_id(); - } - } - - $hash = hash('sha256', $raw_session_id, true); - // Encode as URL-safe base64: replace +/ with -_ and remove padding - return rtrim(strtr(base64_encode($hash), '+/', '-_'), '='); - } - - } \ No newline at end of file diff --git a/taler_turnstile.module b/taler_turnstile.module @@ -3,12 +3,24 @@ /** * @file * Main module file for Turnstile. + * + * The paywall flow is built around a static, cacheable paywall page + * that references a "paivana"-style template in the merchant backend. + * No PHP session is touched on the unprotected path: the gate is a + * cookie minted by /taler-turnstile/paivana once the visitor's wallet + * has paid. */ +use Drupal\Core\Cache\Cache; use Drupal\Core\Entity\EntityInterface; -use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\Display\EntityViewDisplayInterface; -use Drupal\node\NodeInterface; + + +/** + * Cookie name used to grant access to a previously-paid fulfillment URL. + * Computed and verified by Drupal\taler_turnstile\PaivanaCookie. + */ +const TALER_TURNSTILE_COOKIE = 'taler_turnstile_paivana'; /** @@ -51,12 +63,13 @@ function taler_turnstile_form_node_form_alter(&$form, \Drupal\Core\Form\FormStat /** - * Implements hook_entity_view_alter(). Transforms the body of an entity to - * show the Turnstile dialog instead of the full body if the user needs - * to pay to see the full article. + * Implements hook_entity_view_alter(). + * + * For nodes that carry a price category, swap the full-mode build for + * a teaser + paywall button unless the visitor has already proven + * payment (Paivana cookie) or holds an applicable subscription. */ function taler_turnstile_entity_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) { - // Only process nodes with turnstile enabled if ($entity->getEntityTypeId() !== 'node') { return; } @@ -66,206 +79,160 @@ function taler_turnstile_entity_view_alter(array &$build, EntityInterface $entit if (!$node->hasField('field_taler_turnstile_prcat')) { return; } - /** @var \Drupal\Core\Field\EntityReferenceFieldItemList $field */ $field = $node->get('field_taler_turnstile_prcat'); if ($field->isEmpty()) { - \Drupal::logger('taler_turnstile')->debug('No price category selected'); - return FALSE; + return; } - - /** @var TurnstilePriceCategory $price_category */ + /** @var \Drupal\taler_turnstile\Entity\TurnstilePriceCategory $price_category */ $price_category = $field->entity; if (! $price_category) { - \Drupal::logger('taler_turnstile')->debug('Node has no price category, skipping payment.'); return; } - - $view_mode = $display->getMode(); - if ($view_mode !== 'full') { - \Drupal::logger('taler_turnstile')->debug('Turnstile only active for "Full" view mode.'); + if ($display->getMode() !== 'full') { return; } - $subscriptions = $price_category->getFullSubscriptions(); - foreach ($subscriptions as $subscription_id) { - if (_taler_turnstile_is_subscriber ($subscription_id)) { - \Drupal::logger('taler_turnstile')->debug('Subscriber detected, granting access.'); - return; + $request = \Drupal::request(); + $fulfillment_url = $node->toUrl('canonical', ['absolute' => TRUE])->toString(); + + // Subscriber check. Only consult the session if one already exists, + // so unauthenticated bot traffic never causes a session start (which + // would defeat page caching). + if ($request->hasPreviousSession()) { + $subscriptions = $price_category->getFullSubscriptions(); + foreach ($subscriptions as $subscription_id) { + if (_taler_turnstile_is_subscriber($subscription_id)) { + \Drupal::logger('taler_turnstile')->debug('Subscriber detected, granting access.'); + // Do not advertise this response as cacheable: it depends on + // session state. + $build['#cache']['max-age'] = 0; + return; + } } } - // Disable page cache, this page is personalized! - \Drupal::service('page_cache_kill_switch')->trigger(); - - $node_id = $node->id(); - if (_taler_turnstile_has_session_access($node_id)) { - \Drupal::logger('taler_turnstile')->debug('Session has access to this node.'); + // Paywall already cleared by a prior payment? Trust the cookie. + /** @var \Drupal\taler_turnstile\PaivanaCookie $cookies */ + $cookies = \Drupal::service('taler_turnstile.cookie'); + if ($cookies->verify($request, $fulfillment_url)) { + \Drupal::logger('taler_turnstile')->debug('Valid Paivana cookie, granting access to @url', ['@url' => $fulfillment_url]); + $build['#cache']['contexts'][] = 'cookies:' . TALER_TURNSTILE_COOKIE; return; } - /** @var \Drupal\taler_turnstile\TalerMerchantApiService $api_service */ - $api_service = \Drupal::service('taler_turnstile.api_service'); - - $order_info = _taler_turnstile_get_node_order_info ($node_id); - if ($order_info) { - \Drupal::logger('taler_turnstile')->debug('Found existing order @ORDER_ID for this session.', [ '@ORDER_ID' => $order_info['order_id'] ]); - // We have an existing order, check if it was paid - $order_id = $order_info['order_id']; - $order_status = $api_service->checkOrderStatus($order_info['order_id']); - if ($order_status && $order_status['paid']) { - \Drupal::logger('taler_turnstile')->debug('Order was paid, granting session access.'); - _taler_turnstile_grant_session_access($node_id); - if ($order_status['subscription_slug'] ?? FALSE) { - \Drupal::logger('taler_turnstile')->debug('Subscription was purchased, granting subscription access.'); - $subscription_slug = $order_status['subscription_slug']; - $expiration = $order_status['subscription_expiration']; - _taler_turnstile_grant_subscriber_access ($subscription_slug, $expiration); - } - return; - } - if ($order_status && - ($order_status['order_expiration'] ?? 0) < time() + 60) { - // If order expired (or would expire in less than one minute, - // so too soon for the user to still pay it), then ignore it! - $order_info = NULL; - } - if (!$order_status) - { - $order_info = NULL; - } - else - { - \Drupal::logger('taler_turnstile')->debug('Order expires in @future seconds, not creating new one.', ['@future' => ($order_status['order_expiration'] ?? 0) - time ()] ); - } - } - if (!$order_info) { - // Need to try to create a new order - $order_info = $api_service->createOrder($node); - } - if (!$order_info) { - \Drupal::logger('taler_turnstile')->warning('Failed to setup order with Taler merchant backend!'); - $config = \Drupal::config('taler_turnstile.settings'); - $grant_access_on_error = $config->get('grant_access_on_error') ?? TRUE; - if ($grant_access_on_error) { - \Drupal::logger('taler_turnstile')->debug('Could not setup order, disabling Turnstile.'); + // Not subscribed, no cookie -> render the paywall. + $config = \Drupal::config('taler_turnstile.settings'); + $backend_url = $config->get('payment_backend_url'); + if (empty($backend_url)) { + \Drupal::logger('taler_turnstile')->warning('No backend URL configured, cannot present paywall.'); + if ($config->get('grant_access_on_error') ?? TRUE) { return; } - $pay_button = [ + $build = [ '#markup' => '<div class="taler-turnstile-error">' . t('Payment system temporarily unavailable. Please try again later.') . '</div>', ]; + return; } - else - { - _taler_turnstile_store_order_node_mapping($node_id, $order_info); - $pay_button = [ - '#theme' => 'taler_turnstile_payment_button', - '#order_id' => $order_info['order_id'], - '#session_id' => $order_info['session_id'], - '#payment_url' => $order_info['payment_url'], - '#node_title' => $node->getTitle(), - '#price_hint' => $price_category->getPriceHint(), - '#subscription_hint' => $price_category->getSubscriptionHint(), - '#attached' => [ - 'library' => ['taler_turnstile/payment_button'], + + $template_id = $price_category->getTemplateId(); + // Maximum lifetime of the eventually-issued cookie, in seconds. + // Mirrors what we publish on the merchant template. + $max_pickup_delay = \Drupal\taler_turnstile\TalerMerchantApiService::ORDER_VALIDITY_SECONDS; + + // Pre-compute the taler:// pay-template URI so we can both + // advertise it as the Paivana header (for non-JS Taler clients) + // and let the JS render its QR code from the same string. + $proto_suffix = (parse_url($backend_url, PHP_URL_SCHEME) === 'http') ? '+http' : ''; + $host = parse_url($backend_url, PHP_URL_HOST); + $port = parse_url($backend_url, PHP_URL_PORT); + $path = rtrim(parse_url($backend_url, PHP_URL_PATH) ?? '/', '/'); + $authority = $host . ($port ? ':' . $port : ''); + $taler_uri = 'taler' . $proto_suffix . '://pay-template/' . $authority . $path . '/' . rawurlencode($template_id); + + $pay_button = [ + '#theme' => 'taler_turnstile_payment_button', + '#merchant_backend' => $backend_url, + '#template_id' => $template_id, + '#max_pickup_delay' => $max_pickup_delay, + '#node_title' => $node->getTitle(), + '#price_hint' => $price_category->getPriceHint(), + '#subscription_hint' => $price_category->getSubscriptionHint(), + '#fulfillment_url' => $fulfillment_url, + '#confirm_url' => \Drupal\Core\Url::fromRoute('taler_turnstile.paivana_confirm', [], ['absolute' => FALSE])->toString(), + '#paivana_uri' => $taler_uri, + '#attached' => [ + 'library' => ['taler_turnstile/payment_button'], + // Surface the Paivana URI as an HTTP header for non-JS clients, + // and let downstream caches key on the cookie. + 'http_header' => [ + ['Paivana', $taler_uri, FALSE], + ['Vary', 'Cookie', FALSE], ], - ]; - } - // User needs to pay - replace full content with teaser + payment button - // Generate teaser view mode + ], + ]; + $view_builder = \Drupal::entityTypeManager()->getViewBuilder('node'); $teaser_build = $view_builder->view($entity, 'teaser'); - // Replace the build array with teaser content - // Keep important metadata from original build (?) $build = [ - '#cache' => ['contexts' => ['url']], + // Page is identical for everyone *without* the cookie, so it + // remains cacheable. The cookie context only kicks in once the + // visitor has paid. + '#cache' => [ + 'contexts' => array_merge( + $build['#cache']['contexts'] ?? [], + ['cookies:' . TALER_TURNSTILE_COOKIE] + ), + 'tags' => Cache::mergeTags( + $build['#cache']['tags'] ?? [], + $price_category->getCacheTags() + ), + ], '#weight' => $build['#weight'] ?? 0, ]; - // Add teaser content $build['teaser'] = [ '#type' => 'container', '#attributes' => ['class' => ['taler-turnstile-teaser-wrapper']], 'content' => $teaser_build, '#weight' => 0, ]; - - // Add payment button $build['payment_button'] = [ '#type' => 'container', '#attributes' => ['class' => ['taler-turnstile-payment-wrapper']], 'button' => $pay_button, '#weight' => 10, ]; -} - -/** - * Helper function to grant subscription access for this - * visitor to the given node ID until the given expiration time. - */ -function _taler_turnstile_grant_subscriber_access($subscription_slug, $expiration) { - $session = \Drupal::request()->getSession(); - $access_data = $session->get('taler_turnstile_subscriptions', []); - $access_data[$subscription_slug] = $expiration; - $session->set('taler_turnstile_subscriptions', $access_data); } /** - * Helper function to check if this session is currently - * subscribed on the given type of subscription. + * Returns TRUE if the visitor's session has a non-expired entry for + * the given subscription slug. Does NOT start a session if none yet + * exists. */ function _taler_turnstile_is_subscriber($subscription_slug) { - $session = \Drupal::request()->getSession(); + $request = \Drupal::request(); + if (! $request->hasPreviousSession()) { + return FALSE; + } + $session = $request->getSession(); $access_data = $session->get('taler_turnstile_subscriptions', []); return ($access_data[$subscription_slug] ?? 0) >= time(); } /** - * Helper function to grant session access for this - * visitor to the given node ID. - */ -function _taler_turnstile_grant_session_access($node_id) { - $session = \Drupal::request()->getSession(); - $access_data = $session->get('taler_turnstile_access', []); - $access_data[$node_id] = TRUE; - $session->set('taler_turnstile_access', $access_data); -} - - -/** - * Helper function to check session access. Checks if this - * visitor has been granted access to the given $node_id. + * Helper used by the Paivana confirmation controller to record that + * a paid contract bought a subscription valid until $expiration. */ -function _taler_turnstile_has_session_access($node_id) { - $session = \Drupal::request()->getSession(); - $access_data = $session->get('taler_turnstile_access', []); - return $access_data[$node_id] ?? FALSE; -} - - -/** - * Store the mapping between order_id and node_id. - * Uses session to track which orders belong to which nodes. - */ -function _taler_turnstile_store_order_node_mapping($node_id, $order_info) { - $session = \Drupal::request()->getSession(); - $node_orders = $session->get('taler_turnstile_node_orders', []); - $node_orders[$node_id] = $order_info; - $session->set('taler_turnstile_node_orders', $node_orders); -} - - -/** - * Get the order_info associated with a node_id. - */ -function _taler_turnstile_get_node_order_info($node_id) { +function _taler_turnstile_grant_subscriber_access($subscription_slug, $expiration) { $session = \Drupal::request()->getSession(); - $node_orders = $session->get('taler_turnstile_node_orders', []); - return $node_orders[$node_id] ?? NULL; + $access_data = $session->get('taler_turnstile_subscriptions', []); + $access_data[$subscription_slug] = $expiration; + $session->set('taler_turnstile_subscriptions', $access_data); } @@ -276,12 +243,15 @@ function taler_turnstile_theme() { return [ 'taler_turnstile_payment_button' => [ 'variables' => [ - 'order_id' => NULL, - 'session_id' => NULL, - 'payment_url' => NULL, + 'merchant_backend' => NULL, + 'template_id' => NULL, + 'max_pickup_delay' => 86400, 'node_title' => NULL, 'price_hint' => NULL, 'subscription_hint' => NULL, + 'fulfillment_url' => NULL, + 'confirm_url' => NULL, + 'paivana_uri' => NULL, ], 'template' => 'taler-turnstile-payment-button', ], diff --git a/taler_turnstile.routing.yml b/taler_turnstile.routing.yml @@ -1,3 +1,13 @@ +taler_turnstile.paivana_confirm: + path: '/taler-turnstile/paivana' + defaults: + _controller: '\Drupal\taler_turnstile\Controller\PaivanaController::confirm' + methods: [POST] + requirements: + _access: 'TRUE' + options: + no_cache: TRUE + taler_turnstile.settings: path: '/admin/config/system/taler-turnstile' defaults: diff --git a/taler_turnstile.services.yml b/taler_turnstile.services.yml @@ -7,6 +7,10 @@ services: class: Drupal\taler_turnstile\TurnstileFieldManager arguments: ["@entity_type.manager"] + taler_turnstile.cookie: + class: Drupal\taler_turnstile\PaivanaCookie + arguments: ["@private_key"] + logger.channel.taler_turnstile: parent: logger.channel_base arguments: ["taler-turnstile"] @@ -19,6 +23,7 @@ services: "@messenger", "@entity_type.manager", "@taler_turnstile.field_manager", + "@taler_turnstile.api_service", ] tags: - { name: event_subscriber } diff --git a/templates/taler-turnstile-payment-button.html.twig b/templates/taler-turnstile-payment-button.html.twig @@ -1,4 +1,10 @@ -<div class="taler-turnstile-payment-container"> +<div class="taler-turnstile-payment-container" + data-merchant-backend="{{ merchant_backend }}" + data-template-id="{{ template_id }}" + data-fulfillment-url="{{ fulfillment_url }}" + data-confirm-url="{{ confirm_url }}" + data-paivana-uri="{{ paivana_uri }}" + data-max-pickup-delay="{{ max_pickup_delay }}"> <div class="taler-turnstile-payment-info"> <h3>{{ 'Payment required'|t }}</h3> <p>{{ 'Please pay to access'|t }} <strong>{{ node_title }}</strong>.</p> @@ -6,10 +12,7 @@ <div class="taler-turnstile-payment-actions"> <div class="taler-turnstile-payment-qr"> - <div class="taler-turnstile-qr-code-container" - data-payment-url="{{ payment_url }}" - data-order-id="{{ order_id }}" - data-session-id="{{ session_id }}"></div> + <div class="taler-turnstile-qr-code-container"></div> <p class="taler-turnstile-qr-help">{{ 'Scan with your GNU Taler wallet'|t }}</p> </div> @@ -17,10 +20,8 @@ <span>{{ 'or'|t }}</span> </div> - <a href="{{ payment_url }}" - class="button button--primary taler-turnstile-pay-button show-if-taler-supported hidden" - data-order-id="{{ order_id }}" - data-session-id="{{ session_id }}"> + <a href="#" + class="button button--primary taler-turnstile-pay-button show-if-taler-supported hidden"> {{ 'Open GNU Taler payment Web page'|t }} </a> </div>