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 }