turnstile

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

PaivanaController.php (5717B)


      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(string $url): ?NodeInterface {
     69     $base = $this->getRequest()->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     // Resolve any path alias (e.g. "/articles/foo") down to "/node/123".
     77     $internal = $this->aliasManager->getPathByAlias($path);
     78     if (! preg_match('#^/node/(\d+)$#', $internal, $m)) {
     79       return NULL;
     80     }
     81     $node = $this->entityTypeManager()->getStorage('node')->load($m[1]);
     82     return $node instanceof NodeInterface ? $node : NULL;
     83   }
     84 
     85 
     86   /**
     87    * POST handler. Body: {order_id, nonce, cur_time, website}.
     88    * On success, returns 303 + Set-Cookie + Location: $website.
     89    */
     90   public function confirm(Request $request) {
     91     $payload = json_decode((string) $request->getContent(), TRUE);
     92     if (! is_array($payload)) {
     93       return new JsonResponse(['error' => 'malformed_json'], 400);
     94     }
     95     $order_id = $payload['order_id'] ?? NULL;
     96     $nonce = $payload['nonce'] ?? NULL;
     97     $cur_time = $payload['cur_time'] ?? NULL;
     98     $website = $payload['website'] ?? NULL;
     99     if (! is_string($order_id) || ! is_string($nonce) ||
    100         ! is_int($cur_time) || ! is_string($website)) {
    101       return new JsonResponse(['error' => 'missing_or_malformed_fields'], 400);
    102     }
    103     if ($cur_time <= time()) {
    104       return new JsonResponse(['error' => 'expired'], 410);
    105     }
    106 
    107     // Reconstruct the paivana ID exactly the same way the JS did.
    108     $hash = hash('sha256', $nonce . $website . (string) $cur_time, TRUE);
    109     $paivana_id = $cur_time . '-' . rtrim(strtr(base64_encode($hash), '+/', '-_'), '=');
    110 
    111     // Resolve the website to a Drupal node so we can determine the
    112     // price category and thus the set of acceptable amounts.
    113     $node = $this->nodeFromUrl($website);
    114     if (! $node) {
    115       \Drupal::logger('taler_turnstile')->warning('Confirm: cannot resolve fulfillment URL @url to a node', ['@url' => $website]);
    116       return new JsonResponse(['error' => 'unknown_fulfillment_url'], 404);
    117     }
    118     if (! $node->hasField('field_taler_turnstile_prcat') ||
    119         $node->get('field_taler_turnstile_prcat')->isEmpty()) {
    120       return new JsonResponse(['error' => 'no_price_category'], 404);
    121     }
    122     /** @var TurnstilePriceCategory $price_category */
    123     $price_category = $node->get('field_taler_turnstile_prcat')->entity;
    124     if (! $price_category) {
    125       return new JsonResponse(['error' => 'no_price_category'], 404);
    126     }
    127     $expected_amounts = $price_category->getAcceptableAmounts(
    128       $this->apiService->getSubscriptions()
    129     );
    130     if (empty($expected_amounts)) {
    131       return new JsonResponse(['error' => 'no_choices_configured'], 500);
    132     }
    133 
    134     $verified = $this->apiService->verifyPaidOrder(
    135       $order_id,
    136       $paivana_id,
    137       $website,
    138       $expected_amounts
    139     );
    140     if (! $verified) {
    141       return new JsonResponse(['error' => 'order_not_validated'], 402);
    142     }
    143 
    144     // If the contract bought a subscription, persist that in the
    145     // session so subsequent visits can short-circuit even when
    146     // the per-URL cookie expires.
    147     if (! empty($verified['subscription_slug'])) {
    148       _taler_turnstile_grant_subscriber_access(
    149         $verified['subscription_slug'],
    150         (int) $verified['subscription_expiration']
    151       );
    152     }
    153 
    154     $cookie = $this->cookies->mint(
    155       $request,
    156       $website,
    157       TalerMerchantApiService::ORDER_VALIDITY_SECONDS
    158     );
    159     $response = new RedirectResponse($website, 303);
    160     $response->headers->setCookie($cookie);
    161     // The result is per-cookie and short-lived; do not cache.
    162     $response->headers->set('Cache-Control', 'no-store');
    163     return $response;
    164   }
    165 
    166 }