drupal-paivana-manual.rst (21170B)
1 .. 2 This file is part of GNU TALER. 3 4 Copyright (C) 2026 Taler Systems SA 5 6 TALER is free software; you can redistribute it and/or modify it under the 7 terms of the GNU Affero General Public License as published by the Free Software 8 Foundation; either version 2.1, or (at your option) any later version. 9 10 TALER is distributed in the hope that it will be useful, but WITHOUT ANY 11 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 12 A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. 13 14 You should have received a copy of the GNU Affero General Public License along with 15 TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 16 17 @author Christian Grothoff 18 19 .. _drupal-paivana: 20 21 22 Drupal integration 23 ================== 24 25 This chapter documents the installation and operation of the 26 ``taler_turnstile`` Drupal module. The module gates individual 27 nodes (articles, pages, …) of a Drupal 9 or 10 site behind a 28 GNU Taler paywall: visitors who have not yet paid are shown a 29 teaser plus a payment button, and once their wallet has paid the 30 associated merchant template the full content is unlocked. 31 32 The module is designed around the same Paivana-style flow as 33 :ref:`paivana-httpd <Paivana-httpd>`: a static, cacheable paywall 34 page references a *payment template* in the merchant backend, no 35 order is created when a visitor merely loads a paywalled article, 36 and no PHP session is started for unauthenticated traffic. This 37 keeps bot traffic from polluting the merchant backend and lets 38 Drupal's page caches serve the paywall page to anonymous visitors 39 without per-request work. 40 41 42 Architecture overview 43 --------------------- 44 45 A Drupal site using Turnstile combines three independent 46 components: 47 48 1. **The Drupal site.** An ordinary Drupal 9 or 10 installation 49 serving content of one or more content types 50 (``article``, ``page``, …). The ``taler_turnstile`` module 51 adds an entity-reference field called *Price category* to the 52 content types you select, and uses ``hook_entity_view_alter()`` 53 to intercept rendering of nodes that carry a non-empty value. 54 2. **A GNU Taler merchant backend** (``taler-merchant-httpd``). 55 The backend stores one *payment template* per price category 56 defined in Drupal, manages orders, talks to one or more Taler 57 exchanges, and ultimately reports back whether a given order 58 has been paid. See the 59 :ref:`Taler Merchant Backend Operator Manual 60 <taler-merchant-backend-operator-manual>` for full details. 61 3. **The visitor's GNU Taler wallet.** The wallet scans the QR 62 code shown on the paywall page (or follows the 63 ``taler://pay-template/…`` link), pays the merchant template 64 and triggers the unlock. 65 66 Turnstile itself stores no per-visitor state in the Drupal 67 database; once the merchant backend confirms a payment, Turnstile 68 mints a short-lived HMAC cookie (``taler_turnstile_paivana``) 69 that the browser presents on subsequent requests to the same 70 fulfillment URL. Clearing cookies forgets paid access, but 71 GNU Taler wallet's repurchase detection will restore the cookie 72 if the user attempts to pay for the same article again. 73 74 .. note:: 75 76 This feature likely does not interact well with expiration 77 periods for article purchases. This is a bug that will 78 require further work to fully address. See #11443. 79 80 81 Installation 82 ------------ 83 84 Requirements 85 ^^^^^^^^^^^^ 86 87 - Drupal 9 or 10 88 - PHP 8.1 or later (the module uses native enums) 89 - The Drupal core modules ``node``, ``field``, ``user`` and 90 ``path_alias`` (the latter is used by the confirmation 91 endpoint to map fulfillment URLs back to nodes) 92 - A reachable GNU Taler merchant backend supporting the v29 (or 93 newer) ``paivana`` template type and the public 94 ``/sessions/$SESSION_ID`` endpoint 95 - An access token for the merchant instance with the 96 ``templates-write`` and ``orders-read`` permissions 97 98 99 Obtaining the module 100 ^^^^^^^^^^^^^^^^^^^^ 101 102 The module sources are available from the GNU Taler Git repository as well as 103 Drupal's own module repository. Download the latest release and extract it 104 into your Drupal installation's ``modules/custom/`` directory so that the 105 resulting layout is: 106 107 .. code-block:: shell-session 108 109 $ ls modules/custom/taler_turnstile/ 110 config/ js/ taler_turnstile.info.yml 111 src/ templates/ taler_turnstile.module 112 ... 113 114 There is no build step and no Composer package: the module is 115 plain PHP and a couple of JavaScript files (the QR code library 116 is vendored from `davidshimjs/qrcodejs 117 <https://github.com/davidshimjs/qrcodejs>`__). 118 119 120 Enabling the module 121 ^^^^^^^^^^^^^^^^^^^ 122 123 The module can be enabled via Drush or via the Drupal admin 124 interface: 125 126 .. tab-set:: 127 128 .. tab-item:: Drush 129 130 .. code-block:: shell-session 131 132 $ drush en taler_turnstile 133 $ drush cr 134 135 .. tab-item:: Admin UI 136 137 Navigate to ``/admin/modules``, locate *GNU Taler Turnstile* 138 in the *System* package, tick its checkbox and press 139 *Install*. 140 141 .. image:: screenshots/drupal-turnstile-module-enable.png 142 143 After installation, clear the cache so that Drupal picks 144 up the new routes, services and theme template 145 (``drush cr`` or *Configuration → Performance → 146 Clear all caches*). 147 148 Enabling the module triggers ``hook_install()``, which attaches the 149 ``field_taler_turnstile_prcat`` entity-reference field to every content type 150 listed in ``enabled_content_types`` (default: ``article``). 151 152 Uninstalling with ``drush pmu taler_turnstile`` removes the 153 price-category field from every configured content type and 154 deletes the module's configuration. It does **not** delete 155 templates from the merchant backend; remove those manually if 156 desired (see :ref:`template`). 157 158 159 Configuring the basics 160 ---------------------- 161 162 The main settings page lives at 163 ``/admin/config/system/taler-turnstile`` and is also reachable 164 through *Configuration → System → GNU Taler Turnstile basics*. 165 It collects the four pieces of information Turnstile needs to 166 talk to the merchant backend and to know which content to gate: 167 168 .. image:: screenshots/drupal-turnstile-settings-form.png 169 170 The fields are: 171 172 - **Enabled content types** — a list of checkboxes derived from 173 the site's configured node bundles. Each ticked content type 174 gains the *Price category* field (and gets it removed again 175 when unticked). The change takes effect immediately on save: 176 the field is created or destroyed as a side-effect of saving 177 this form, not via ``drush cim``. 178 - **Payment backend URL** — the HTTP(S) base URL of the merchant 179 backend. Instance-specific URLs ending in ``/instances/$ID/`` 180 are accepted; the URL **must end with a trailing slash**. When 181 you save the form with a non-empty value, Turnstile contacts the 182 backend's ``/config`` endpoint to verify reachability before 183 accepting the value. 184 - **Access token** — the Bearer token used for every call to the 185 backend. It **must begin with** ``secret-token:`` (following 186 RFC 8959, see :ref:`Access control 187 <taler-merchant-backend-operator-manual>` in the merchant 188 manual). When both URL and token are present, the form 189 performs a live authentication check against the backend 190 and an error is shown if the access token is incorrect. 191 - **Disable Turnstile when payment backend is unavailable** 192 (fail-open toggle, on by default) — when the merchant backend 193 cannot be reached, this controls whether visitors see the 194 unpaywalled article (toggle on) or an error message in place 195 of the payment button (toggle off). The settings form refuses 196 to save with the toggle off and an empty backend URL or token 197 unless you confirm the warning, so a misconfiguration cannot 198 silently break the live site. 199 200 The first time you save a working backend URL and access token, 201 Turnstile pushes a ``paivana``-style template to the merchant 202 backend for every price category that already exists. Each 203 template carries the ID ``turnstile-{category_id}``. You do not 204 need to create those templates by hand, and you do not need to 205 visit the merchant backend's SPA to manage them — Turnstile keeps 206 them in sync as you edit the categories (see 207 :ref:`drupal-paivana-price-categories` below). 208 209 Before continuing, make sure that the merchant backend instance 210 itself is ready to take payments: 211 212 - The instance has at least one configured 213 :ref:`bank account <instance-bank-account>`. 214 - Any legitimization required by the chosen payment service 215 provider has been completed. 216 217 218 .. _drupal-paivana-subscription-prices: 219 220 Configuring subscription prices 221 ------------------------------- 222 223 .. note:: 224 225 The term *subscription* is slightly misleading, as it is not 226 auto-renewing. We are in the process of renaming this to *passes* which is 227 clearer. However, the current module uses subscription and thus so does this 228 manual. 229 230 Turnstile supports the GNU Taler subscription model, in which a 231 visitor buys a *subscription token* once and the token is then 232 consumed to unlock individual articles for some validity period. 233 The catalogue of available *token families* is configured on the 234 merchant backend (see the merchant manual section on 235 :ref:`templates <template>` and the wider Taler concept of 236 subscription tokens); Turnstile reads the catalogue live from 237 ``private/tokenfamilies`` and asks you to set the 238 *purchase price* per token family per currency. 239 240 Navigate to 241 ``/admin/config/system/taler_turnstile/subscription-prices`` 242 (also reachable as *Configuration → System → GNU Taler Turnstile 243 subscription prices*). The form lists one collapsible *Details* 244 block per token family advertised by the backend, with one 245 numeric input per supported currency: 246 247 .. image:: screenshots/drupal-turnstile-subscription-prices.png 248 249 Leaving a field empty disables the *Buy subscription in 250 {currency}* payment choice for that combination — the 251 subscription will then not be offered for sale in 252 that currency. 253 254 If the merchant backend has no token families configured, the 255 form shows a warning and offers nothing to edit. In that case 256 you can still operate Turnstile in pure pay-per-article mode; 257 simply skip this configuration step. 258 259 Whenever this form is saved, Turnstile republishes **every** 260 price-category template against the backend so the new 261 subscription prices appear in the wallet's *Buy subscription* 262 choices immediately. 263 264 .. note:: 265 266 GNU Taler token families offer more flexible payment options, not just 267 subscriptions. However, only subscriptions are supported at this 268 time. 269 270 271 .. _drupal-paivana-price-categories: 272 273 Defining price categories 274 ------------------------- 275 276 A *price category* is a named bucket of payment options that can 277 be attached to nodes. Categories are managed at 278 ``/admin/structure/taler-turnstile-price-categories`` (also 279 reachable as *Structure → GNU Taler Turnstile price 280 categories*). The collection page lists the existing categories 281 together with their machine names and descriptions and offers 282 *Edit*, *Delete* and *Add price category* actions: 283 284 .. image:: screenshots/drupal-turnstile-price-category-list.png 285 286 287 Creating or editing a price category 288 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 289 290 Click *Add price category* (or *Edit* on an existing row) to 291 reach the price-category form. The form has three sections: 292 293 - **Name** — human-readable label for the category (e.g. 294 *Standard article*, *Long-form feature*). Shown to editors 295 when they pick a category on a node form. 296 - **Machine name** — the slug used to build the merchant 297 template ID (``turnstile-{machine_name}``). Derived 298 automatically from the initial **Name**; fixed once the 299 category exists. 300 - **Description** — free text shown to editors in the node form 301 to help them pick the right category. 302 - **Prices** — a fieldset with one collapsible *Details* block 303 per known token family plus a special block labelled 304 ``No reduction`` that holds the price for non-subscribers. Each 305 block contains one numeric input per supported currency. 306 307 .. image:: screenshots/drupal-turnstile-price-category-edit.png 308 309 Pricing semantics: 310 311 - A value in the ``No reduction`` block sets the **per-article price 312 for visitors without a subscription** in that currency. Leave 313 it empty to make the article available **only** to 314 subscribers in that currency. 315 - A value in a subscription block sets the **discounted price 316 for holders of that subscription token** in that currency. 317 A value of exactly ``0`` marks the article as *fully covered* 318 by that subscription — subscribers see it for free. An empty 319 field means "this subscription does not unlock articles of 320 this category in this currency", forcing subscribers 321 (with this type of subscription) to pay the full per-article 322 price. 323 324 At least one price (across all blocks and currencies) must be 325 set; otherwise the form refuses to save as there would be no 326 way to purchase articles in that price category otherwise. 327 328 Saving the form publishes the category as a 329 ``paivana``-style template to the merchant backend (the v1 330 ``choices`` array is built from the price grid; see 331 :ts:type:`TemplateContractPaivana` in the 332 :ref:`Merchant Backend HTTP API <merchant-api>`). If the local 333 save succeeds but the backend push fails (network error, 401, 334 ...), the form surfaces an error message. 335 336 .. note:: 337 338 In this case, the local price in Drupal and the merchant template 339 may have drifted out of sync. Visitors would still pay the price in 340 the merchant backend, so this error should always be investigated. 341 342 Editing a category, renaming it, or changing prices 343 automatically refreshes the matching merchant template. 344 Deleting a category removes its template; if the delete on the 345 backend fails, you are warned to clean it up manually. 346 347 348 Gating individual nodes 349 ----------------------- 350 351 Once a content type is *enabled* in the basic settings (see 352 above) and at least one price category exists, every node of 353 that content type gains a new *Price category* field in its 354 editing form. By default the field is placed in the *meta* 355 group on the right-hand side of the node form. 356 357 .. image:: screenshots/drupal-turnstile-node-edit.png 358 359 To paywall a node: 360 361 1. Edit or create a node of an enabled content type. 362 2. Pick a price category in the *Price category* field. 363 3. Save the node. 364 365 Leaving the field empty leaves the node freely accessible — the 366 paywall logic only fires for nodes that reference a category. 367 368 If you want to remove the paywall from a node, edit it and clear 369 the field. If you want to lift the paywall from an entire 370 content type, untick it in the *Enabled content types* list on 371 the settings form; the field and its values are then removed. 372 (The field's contents are dropped, so re-enabling the content 373 type later starts from a clean slate, forcing you to set the 374 price categories again for all nodes in that category.) 375 376 377 The visitor experience 378 ---------------------- 379 380 Anonymous visitor without a cookie 381 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 382 383 For a node that references a price category, an anonymous 384 visitor without a valid ``taler_turnstile_paivana`` cookie sees: 385 386 - The node rendered in its *teaser* view mode. A soft fade-out 387 is applied at the bottom of the teaser so it is visually clear 388 that there is more content behind the paywall. 389 - A *Payment required* block immediately below the teaser, with 390 a QR code (encoding the ``taler://pay-template/…`` URI for the 391 matching merchant template), a *Scan with your GNU Taler 392 wallet* hint, and — when the browser advertises support for 393 the ``taler`` URI scheme — an *Open GNU Taler payment Web 394 page* button. 395 - A pricing summary showing the per-article price (or "Not sold 396 individually" if only subscriptions unlock the category) and 397 the list of subscriptions that accept this category. 398 - A *Waiting for payment…* status line that updates once the 399 wallet picks up the order. 400 401 .. image:: screenshots/drupal-turnstile-paywall-page.png 402 403 The page is identical for every visitor without the cookie, so 404 Drupal's page cache serves it without per-request work. The 405 ``taler://pay-template/…`` URI is also advertised as a 406 ``Paivana:`` HTTP header for non-JavaScript Taler clients. 407 408 Behind the scenes, the JavaScript on the page does **not** 409 create an order in the merchant backend. It generates a fresh 410 nonce, derives a session ID from it, renders the QR code, and 411 long-polls the public ``sessions/{paivana_id}`` endpoint on the 412 merchant backend waiting for the wallet to pick the template up 413 and pay. No order, no PHP session and no database write happens 414 until the visitor's wallet actually engages with the template. 415 416 417 Paying 418 ^^^^^^ 419 420 The visitor scans the QR code with a `GNU Taler wallet 421 <https://docs.taler.net/wallet/>`__ (or clicks the *Open GNU 422 Taler payment Web page* button when running a browser with a 423 wallet extension). The wallet shows the available payment 424 choices — for a typical category these are: 425 426 - *Pay in {currency}* — the per-article price for non-subscribers. 427 - *Pay in {currency} with subscription* — only offered to visitors 428 whose wallet holds a matching, unspent subscription token (in 429 which case it usually costs ``{currency}:0``). 430 - *Buy subscription in {currency}* — pays the per-article price 431 plus the subscription's purchase price (from 432 :ref:`drupal-paivana-subscription-prices` above) and yields a 433 fresh subscription token in the visitor's wallet. 434 435 The visitor picks an option, the wallet pays, and Drupal's 436 JavaScript proceeds to the confirmation step. 437 438 439 Confirmation and unlock 440 ^^^^^^^^^^^^^^^^^^^^^^^ 441 442 Once the merchant backend reports the order as paid, the 443 JavaScript POSTs ``{order_id, nonce, cur_time, website}`` to 444 ``/taler-turnstile/paivana``. The Drupal-side controller 445 re-derives the session ID, fetches the order from the merchant 446 backend and **verifies that the paid contract's 447 ``fulfillment_url`` matches the requesting page and that the 448 paid amount is one of the amounts the price category accepts**. 449 Only then does it mint the ``taler_turnstile_paivana`` cookie 450 and 303-redirect the browser back to the article. 451 452 The cookie is keyed on Drupal's per-site ``private_key``, bound 453 to the visitor's client IP and to the specific fulfillment URL, 454 marked ``HttpOnly``, ``SameSite=Lax``, and ``Secure`` when the 455 page was served over HTTPS. It is valid for 24 hours (the 456 ``ORDER_VALIDITY_SECONDS`` constant in the API service; this 457 will follow the merchant-supplied 458 ``pay_deadline``/``max_pickup_time`` once backend v1.6 lands). 459 The cookie covers exactly one fulfillment URL — clearing 460 cookies forgets paid access, and access to a different 461 paywalled article requires a fresh payment (or a valid 462 subscription). 463 464 .. note:: 465 466 ORDER_VALIDITY_SECONDS should probably be made configurable 467 in the future, as should the restriction of the client to 468 a particular IP address, as that may or may not be desired. 469 See #11444. 470 471 472 Subscriptions in action 473 ^^^^^^^^^^^^^^^^^^^^^^^ 474 475 When a paid contract bought a subscription token, Turnstile 476 also stores the token family slug and the token's expiration 477 time in the visitor's PHP session. Subsequent requests to 478 other articles whose price category is fully covered by the 479 same subscription short-circuit the paywall entirely: the 480 visitor never sees the *Payment required* block again until the 481 token expires. 482 483 Visitors without a session cookie still see the paywall page 484 (so anonymous, cache-friendly traffic stays cacheable). 485 486 487 Operational notes 488 ----------------- 489 490 Logging 491 ^^^^^^^ 492 493 The module logs to the dedicated ``taler-turnstile`` channel. 494 To watch the live event stream while debugging: 495 496 .. code-block:: shell-session 497 498 $ drush watchdog:tail --type=taler-turnstile 499 500 Levels are used meaningfully: ``debug`` for flow tracing, 501 ``info`` for expected skips (for example, a subscription slug 502 the backend no longer advertises), ``warning`` for user-fixable 503 configuration issues, and ``error`` for protocol violations or 504 unexpected HTTP statuses from the backend. 505 506 507 Cache management 508 ^^^^^^^^^^^^^^^^ 509 510 The module declares ``cookies:taler_turnstile_paivana`` as a 511 cache context on every paywalled response, so Drupal's internal 512 page cache and ``dynamic_page_cache`` separate visitors with a 513 cookie from those without. A reverse-proxy cache in front of 514 Drupal must therefore honour the ``Vary: Cookie`` header to 515 avoid handing one paying visitor's full-content response to 516 another visitor. 517 518 519 Configuration import/export 520 ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 521 522 Both ``taler_turnstile.settings`` and each 523 ``taler_turnstile_price_category.*`` config entity participate in 524 the normal Drupal ``drush cex`` / ``drush cim`` workflow. 525 Importing a configuration change that adds or removes content 526 types from ``enabled_content_types`` triggers field 527 add/removal; importing a price-category change republishes the 528 matching merchant template. 529 530 531 Permissions 532 ^^^^^^^^^^^ 533 534 Two permissions are exposed: 535 536 - **Administer GNU Taler Turnstile** — grants access to the 537 *basics* and *subscription prices* settings forms. 538 - **Administer price categories** — grants access to the 539 price-category CRUD pages. 540 541 Both default to *restrict access* and should normally only be 542 granted to trusted administrators.