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:
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>