taler-docs

Documentation for GNU Taler components, APIs and protocols
Log | Files | Refs | README | LICENSE

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.