turnstile

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

PaivanaController.php (8455B)


      1 <?php
      2 
      3 /**
      4  * @file
      5  * Location: src/Controller/PaivanaController.php
      6  *
      7  * Confirms a paid Paivana-style purchase, validates the contract
      8  * matches a known fulfillment URL and price category, and on success
      9  * mints the access cookie and redirects.
     10  */
     11 
     12 namespace Drupal\taler_turnstile\Controller;
     13 
     14 use Drupal\Core\Controller\ControllerBase;
     15 use Drupal\Core\Url;
     16 use Drupal\node\NodeInterface;
     17 use Drupal\path_alias\AliasManagerInterface;
     18 use Drupal\taler_turnstile\Entity\TurnstilePriceCategory;
     19 use Drupal\taler_turnstile\PaivanaCookie;
     20 use Drupal\taler_turnstile\TalerMerchantApiService;
     21 use Symfony\Component\DependencyInjection\ContainerInterface;
     22 use Symfony\Component\HttpFoundation\JsonResponse;
     23 use Symfony\Component\HttpFoundation\RedirectResponse;
     24 use Symfony\Component\HttpFoundation\Request;
     25 
     26 /**
     27  * Controller backing /taler-turnstile/paivana.
     28  */
     29 class PaivanaController extends ControllerBase {
     30 
     31   /**
     32    * @var \Drupal\taler_turnstile\TalerMerchantApiService
     33    */
     34   protected $apiService;
     35 
     36   /**
     37    * @var \Drupal\taler_turnstile\PaivanaCookie
     38    */
     39   protected $cookies;
     40 
     41   /**
     42    * @var \Drupal\path_alias\AliasManagerInterface
     43    */
     44   protected $aliasManager;
     45 
     46   public function __construct(TalerMerchantApiService $api_service,
     47                               PaivanaCookie $cookies,
     48                               AliasManagerInterface $alias_manager) {
     49     $this->apiService = $api_service;
     50     $this->cookies = $cookies;
     51     $this->aliasManager = $alias_manager;
     52   }
     53 
     54   public static function create(ContainerInterface $container) {
     55     return new static(
     56       $container->get('taler_turnstile.api_service'),
     57       $container->get('taler_turnstile.cookie'),
     58       $container->get('path_alias.manager')
     59     );
     60   }
     61 
     62 
     63   /**
     64    * Reverse $url to the node it canonically points at, so we can
     65    * load its price category. Returns NULL if no node could be
     66    * resolved.
     67    */
     68   protected function nodeFromUrl(Request $request, string $url): ?NodeInterface {
     69     $base = $request->getSchemeAndHttpHost();
     70     if (strpos($url, $base) !== 0) {
     71       // The fulfillment URL must live on this site, otherwise we
     72       // have nothing meaningful to validate against.
     73       return NULL;
     74     }
     75     $path = parse_url($url, PHP_URL_PATH) ?: '/';
     76     // Strip the language prefix (e.g. "/en/node/3" -> "/node/3") so
     77     // the alias manager, which is language-agnostic, can resolve it.
     78     foreach ($this->languageManager()->getLanguages() as $language) {
     79       $prefix = '/' . $language->getId();
     80       if ($path === $prefix || strpos($path, $prefix . '/') === 0) {
     81         $path = substr($path, strlen($prefix));
     82         if ($path === '') {
     83           $path = '/';
     84         }
     85         break;
     86       }
     87     }
     88     // Resolve any path alias (e.g. "/articles/foo") down to "/node/123".
     89     $internal = $this->aliasManager->getPathByAlias($path);
     90     if (! preg_match('#^/node/(\d+)$#', $internal, $m)) {
     91       return NULL;
     92     }
     93     $node = $this->entityTypeManager()->getStorage('node')->load($m[1]);
     94     return $node instanceof NodeInterface ? $node : NULL;
     95   }
     96 
     97 
     98   /**
     99    * Decode a Crockford base32-encoded string (GNUnet flavor:
    100    * alphabet "0123456789ABCDEFGHJKMNPQRSTVWXYZ") into raw bytes.
    101    * Returns FALSE on malformed input.
    102    */
    103   protected static function crock32Decode(string $encoded) {
    104     static $rmap = NULL;
    105     if ($rmap === NULL) {
    106       $rmap = [];
    107       $table = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
    108       for ($i = 0; $i < 32; $i++) {
    109         $rmap[$table[$i]] = $i;
    110       }
    111     }
    112     $encoded = strtoupper($encoded);
    113     $bytes = '';
    114     $buf = 0;
    115     $bits = 0;
    116     $len = strlen($encoded);
    117     for ($i = 0; $i < $len; $i++) {
    118       $c = $encoded[$i];
    119       if (!isset($rmap[$c])) {
    120         return FALSE;
    121       }
    122       $buf = ($buf << 5) | $rmap[$c];
    123       $bits += 5;
    124       if ($bits >= 8) {
    125         $bytes .= chr(($buf >> ($bits - 8)) & 0xFF);
    126         $bits -= 8;
    127       }
    128     }
    129     return $bytes;
    130   }
    131 
    132   /**
    133    * POST handler. Body: {order_id, nonce, cur_time, website}.
    134    * On success, returns 303 + Set-Cookie + Location: $website.
    135    *
    136    * Wire format follows the canonical Paivana spec (see
    137    * paivana-httpd_pay.c / paivana-httpd_cookie.c):
    138    *   - nonce: Crockford base32-encoded 16 binary bytes
    139    *   - cur_time: GNUnet timestamp object {"t_s": <seconds>}
    140    *   - paivana_id := <seconds> "-" b64url(SHA256(
    141    *         nonce_bytes || website_bytes || "\0" || us_BE64))
    142    */
    143   public function confirm(Request $request) {
    144     $payload = json_decode((string) $request->getContent(), TRUE);
    145     if (! is_array($payload)) {
    146       return new JsonResponse(['error' => 'malformed_json'], 400);
    147     }
    148     $order_id = $payload['order_id'] ?? NULL;
    149     $nonce = $payload['nonce'] ?? NULL;
    150     $cur_time_obj = $payload['cur_time'] ?? NULL;
    151     $website = $payload['website'] ?? NULL;
    152     if (! is_string($order_id) || ! is_string($nonce) ||
    153         ! is_array($cur_time_obj) || ! isset($cur_time_obj['t_s']) ||
    154         ! is_int($cur_time_obj['t_s']) || ! is_string($website)) {
    155       return new JsonResponse(['error' => 'missing_or_malformed_fields'], 400);
    156     }
    157     $cur_time = $cur_time_obj['t_s'];
    158     if ($cur_time <= time()) {
    159       return new JsonResponse(['error' => 'expired'], 410);
    160     }
    161 
    162     // Canonical PAIVANA_Nonce is 16 bytes (4 * uint32_t), encoded
    163     // on the wire as Crockford base32 by GNUNET_JSON_spec_fixed_auto.
    164     $nonce_bytes = self::crock32Decode($nonce);
    165     if ($nonce_bytes === FALSE || strlen($nonce_bytes) < 16) {
    166       return new JsonResponse(['error' => 'bad_nonce'], 400);
    167     }
    168     $nonce_bytes = substr($nonce_bytes, 0, 16);
    169 
    170     // Reconstruct the paivana ID exactly the same way the JS did.
    171     // The hash input is: 16-byte binary nonce || website || NUL ||
    172     // 8-byte big-endian microseconds.
    173     $cur_time_be = pack('J', $cur_time * 1000000);
    174     $hash = hash('sha256',
    175                  $nonce_bytes . $website . "\0" . $cur_time_be,
    176                  TRUE);
    177     $paivana_id = $cur_time . '-' . rtrim(strtr(base64_encode($hash), '+/', '-_'), '=');
    178 
    179     // Resolve the website to a Drupal node so we can determine the
    180     // price category and thus the set of acceptable amounts.
    181     $node = $this->nodeFromUrl($request, $website);
    182     if (! $node) {
    183       \Drupal::logger('taler_turnstile')->warning('Confirm: cannot resolve fulfillment URL @url to a node', ['@url' => $website]);
    184       return new JsonResponse(['error' => 'unknown_fulfillment_url'], 404);
    185     }
    186     if (! $node->hasField('field_taler_turnstile_prcat') ||
    187         $node->get('field_taler_turnstile_prcat')->isEmpty()) {
    188       return new JsonResponse(['error' => 'no_price_category'], 404);
    189     }
    190     /** @var TurnstilePriceCategory $price_category */
    191     $price_category = $node->get('field_taler_turnstile_prcat')->entity;
    192     if (! $price_category) {
    193       return new JsonResponse(['error' => 'no_price_category'], 404);
    194     }
    195     // getSubscriptions() always returns a result with at least the
    196     // "%none%" entry in data, even on backend failure, so the
    197     // acceptable-amounts list can still be computed for non-subscription
    198     // prices when the backend is briefly unreachable.
    199     $subs_result = $this->apiService->getSubscriptions();
    200     $subscriptions = is_array($subs_result->data) ? $subs_result->data : [];
    201     $expected_amounts = $price_category->getAcceptableAmounts($subscriptions);
    202     if (empty($expected_amounts)) {
    203       return new JsonResponse(['error' => 'no_choices_configured'], 500);
    204     }
    205 
    206     $verified = $this->apiService->verifyPaidOrder(
    207       $order_id,
    208       $paivana_id,
    209       $website,
    210       $expected_amounts
    211     );
    212     if (! $verified) {
    213       return new JsonResponse(['error' => 'order_not_validated'], 402);
    214     }
    215 
    216     // If the contract bought a subscription, persist that in the
    217     // session so subsequent visits can short-circuit even when
    218     // the per-URL cookie expires.
    219     if (! empty($verified['subscription_slug'])) {
    220       _taler_turnstile_grant_subscriber_access(
    221         $verified['subscription_slug'],
    222         (int) $verified['subscription_expiration']
    223       );
    224     }
    225 
    226     $cookie = $this->cookies->mint(
    227       $request,
    228       $website,
    229       TalerMerchantApiService::ORDER_VALIDITY_SECONDS
    230     );
    231     $response = new RedirectResponse($website, 303);
    232     $response->headers->setCookie($cookie);
    233     // The result is per-cookie and short-lived; do not cache.
    234     $response->headers->set('Cache-Control', 'no-store');
    235     return $response;
    236   }
    237 
    238 }