TalerMerchantApiService.php (37225B)
1 <?php 2 3 /** 4 * @file 5 * Location: src/TalerMerchantApiService.php 6 * 7 * Service for interacting with the Taler Merchant Backend. 8 */ 9 10 namespace Drupal\taler_turnstile; 11 12 use Drupal\Core\Http\ClientFactory; 13 use Psr\Log\LoggerInterface; 14 use Drupal\taler_turnstile\Entity\TurnstilePriceCategory; 15 use GuzzleHttp\Exception\RequestException; 16 use Drupal\Core\StringTranslation\StringTranslationTrait; 17 18 19 /** 20 * Taler error codes used in this module. We do not define 21 * the full list here as that would be excessive and could 22 * just slow down PHP unnecessarily. 23 */ 24 enum TalerErrorCode: int { 25 case TALER_EC_NONE = 0; 26 case TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN = 2000; 27 case TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN = 2005; 28 case TALER_EC_MERCHANT_GENERIC_TEMPLATE_UNKNOWN = 2030; 29 case TALER_EC_MERCHANT_PRIVATE_POST_TEMPLATES_CONFLICT_TEMPLATE_EXISTS = 2603; 30 } 31 32 33 /** 34 * Service for fetching subscriptions and currencies from external API. 35 */ 36 class TalerMerchantApiService { 37 38 /** 39 * For i18n, gives us the t() function. 40 */ 41 use StringTranslationTrait; 42 43 /** 44 * How long are orders valid by default? 24h. 45 */ 46 const ORDER_VALIDITY_SECONDS = 86400; 47 48 /** 49 * How long do we cache /config and token family data from the backend? 50 */ 51 const CACHE_BACKEND_DATA_SECONDS = 60; 52 53 /** 54 * Merchant backend protocol version (libtool "current") required by 55 * this module. The backend's /config "version" string is libtool-style 56 * "CURRENT:REVISION:AGE": the backend supports interfaces in the range 57 * [CURRENT-AGE, CURRENT], so we require this number to fall in that 58 * range. 59 */ 60 const REQUIRED_PROTOCOL_VERSION = 29; 61 62 /** 63 * The HTTP client factory. 64 * 65 * @var \Drupal\Core\Http\ClientFactory 66 */ 67 protected $httpClientFactory; 68 69 /** 70 * The logger. 71 * 72 * @var \Psr\Log\LoggerInterface 73 */ 74 protected $logger; 75 76 /** 77 * Constructs a TalerMerchantApiService object. 78 * 79 * @param \Drupal\Core\Http\ClientFactory $http_client_factory 80 * The HTTP client factory. 81 * @param \Psr\Log\LoggerInterface $logger 82 * The logger. 83 */ 84 public function __construct(ClientFactory $http_client_factory, LoggerInterface $logger) { 85 $this->httpClientFactory = $http_client_factory; 86 $this->logger = $logger; 87 } 88 89 90 /** 91 * Return the base URL for the given backend URL (without instance!) 92 * 93 * @param string $backend_url 94 * Backend URL to check, may include '/instances/$ID' path 95 * @return string|null 96 * base URL, or NULL if the backend URL is invalid 97 */ 98 private function getBaseURL(string $backend_url) { 99 if (empty($backend_url)) { 100 return NULL; 101 } 102 if (!str_ends_with($backend_url, '/')) { 103 return NULL; 104 } 105 $parsed_url = parse_url($backend_url); 106 $path = $parsed_url['path'] ?? '/'; 107 $cleaned_path = preg_replace('#^/instances/[^/]+/?#', '/', $path); 108 $base = $parsed_url['scheme'] . '://' . $parsed_url['host']; 109 if (isset($parsed_url['port'])) { 110 $base .= ':' . $parsed_url['port']; 111 } 112 return $base . $cleaned_path; 113 } 114 115 116 /** 117 * Checks if the given backend URL points to a Taler merchant backend. 118 * 119 * @param string $backend_url 120 * Backend URL to check, may include '/instances/$ID' path 121 * @return bool 122 * TRUE if this is a valid backend URL for a Taler backend 123 * that speaks a protocol compatible with this module 124 */ 125 public function checkConfig(string $backend_url) { 126 $base_url = $this->getBaseURL($backend_url); 127 if (NULL === $base_url) { 128 return FALSE; 129 } 130 try { 131 $http_client = $this->httpClientFactory->fromOptions([ 132 'http_errors' => false, 133 'allow_redirects' => TRUE, 134 'timeout' => 5, // seconds 135 ]); 136 $response = $http_client->get($base_url . 'config'); 137 if ($response->getStatusCode() !== 200) { 138 return FALSE; 139 } 140 $body = json_decode($response->getBody(), TRUE); 141 if (!isset($body['name']) || $body['name'] !== 'taler-merchant') { 142 return FALSE; 143 } 144 if (!isset($body['version']) || !is_string($body['version'])) { 145 $this->logger->error('Taler merchant backend /config response is missing the "version" field; cannot verify protocol compatibility.'); 146 return FALSE; 147 } 148 return $this->checkVersion($body['version']); 149 } catch (\Exception $e) { 150 return FALSE; 151 } 152 } 153 154 155 /** 156 * Verify that a libtool-style "CURRENT:REVISION:AGE" version string 157 * advertises support for self::REQUIRED_PROTOCOL_VERSION. The backend 158 * supports interfaces in [CURRENT-AGE, CURRENT]; we require the 159 * required version to lie within that range. Logs an error when it 160 * does not. 161 * 162 * @param string $version 163 * The "version" field from the backend's /config response. 164 * @return bool 165 * TRUE iff the backend speaks a compatible protocol version. 166 */ 167 private function checkVersion(string $version): bool { 168 $parts = explode(':', $version); 169 if (count($parts) !== 3 170 || !ctype_digit($parts[0]) 171 || !ctype_digit($parts[1]) 172 || !ctype_digit($parts[2])) { 173 $this->logger->error('Taler merchant backend reported malformed version "@version" (expected libtool-style CURRENT:REVISION:AGE).', [ 174 '@version' => $version, 175 ]); 176 return FALSE; 177 } 178 $current = (int) $parts[0]; 179 $age = (int) $parts[2]; 180 $required = self::REQUIRED_PROTOCOL_VERSION; 181 if ($current < $required) { 182 $this->logger->error('Taler merchant backend protocol version "@version is too old; this module requires protocol v@required or newer.', [ 183 '@version' => $version, 184 '@required' => $required, 185 ]); 186 return FALSE; 187 } 188 if ($current - $age > $required) { 189 $this->logger->warning('Taler merchant backend protocol version "@version" MAY no longer support v@required required by this module. Proceed with caution.', [ 190 '@version' => $version, 191 '@required' => $required, 192 ]); 193 return TRUE; 194 } 195 return TRUE; 196 } 197 198 /** 199 * Checks if the given backend URL points to a Taler merchant backend. 200 * 201 * @param string $backend_url 202 * Backend URL to check, may include '/instances/$ID' path 203 * @param string $access_token 204 * Access token to talk to the instance 205 * @return int 206 * HTTP status from a plain GET to the order list, 207 * 200 or 204 if the backend is configured and accessible, 208 * 0 on other error, otherwise HTTP status code indicating the error 209 */ 210 public function checkAccess(string $backend_url, string $access_token) { 211 try { 212 $http_client = $this->httpClientFactory->fromOptions([ 213 'headers' => [ 214 'Authorization' => 'Bearer ' . $access_token, 215 ], 216 // Do not throw exceptions on 4xx/5xx status codes 217 'http_errors' => false, 218 'allow_redirects' => TRUE, 219 'timeout' => 5, // seconds 220 ]); 221 $response = $http_client->get( 222 $backend_url . 'private/orders?limit=1' 223 ); 224 return $response->getStatusCode(); 225 } catch (\Exception $e) { 226 return 0; 227 } 228 } 229 230 /** 231 * Gets the list of available subscriptions. Always includes a special 232 * entry for "No reduction" with ID "". 233 * 234 * @return array 235 * Array mapping token family IDs to subscription data each with a 'name' and 'label' (usually the slug), 'description' and 'description_i18n'. 236 */ 237 public function getSubscriptions() { 238 $cid = 'taler_turnstile:subscriptions'; 239 if ($cache = \Drupal::cache()->get($cid)) { 240 return $cache->data; 241 } 242 243 // Per default, we always have "no subscription" as an option. 244 $result = []; 245 $description = $this->t('No subscription', [], [ 246 'langcode' => 'en', // force English version here! 247 ]); 248 $description_i18n = $this->buildTranslationMap ( 249 'No subscription'); 250 $result['%none%'] = [ 251 'name' => 'none', 252 'label' => 'No reduction', 253 'description' => $description, 254 'description_i18n' => $description_i18n, 255 ]; 256 $config = \Drupal::config('taler_turnstile.settings'); 257 $backend_url = $config->get('payment_backend_url'); 258 $access_token = $config->get('access_token'); 259 260 if (empty($backend_url) || 261 empty($access_token)) { 262 $this->logger->debug('No GNU Taler Turnstile backend configured, returning "none" for subscriptions.'); 263 return $result; 264 } 265 266 $jbody = []; 267 try { 268 $http_client = $this->httpClientFactory->fromOptions([ 269 'headers' => [ 270 'Authorization' => 'Bearer ' . $access_token, 271 ], 272 // Do not throw exceptions on 4xx/5xx status codes 273 'http_errors' => false, 274 'allow_redirects' => TRUE, 275 'timeout' => 5, // seconds 276 ]); 277 $response = $http_client->get($backend_url . 'private/tokenfamilies'); 278 // Get JSON result parsed as associative array 279 $http_status = $response->getStatusCode(); 280 $body = $response->getBody(); 281 $jbody = json_decode($body, TRUE); 282 switch ($http_status) 283 { 284 case 200: 285 if (! isset($jbody['token_families'])) { 286 $this->logger->error('Failed to obtain token family list: HTTP success response unexpectedly lacks "token_families" field.'); 287 return $result; 288 } 289 // Success, handled below 290 break; 291 case 204: 292 // empty list 293 return $result; 294 case 403: 295 $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your GNU Taler Turnstile configuration!'); 296 return $result; 297 case 404: 298 $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 299 $this->logger->error('Failed to fetch token family list: @hint (@ec): @body', ['@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']); 300 return $result; 301 default: 302 $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 303 $this->logger->error('Unexpected HTTP status code @status trying to fetch token family list: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@detail' => $jbody['detail'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']); 304 return $result; 305 } // end switch on HTTP status 306 307 $tokenFamilies = $jbody['token_families']; 308 $now = time (); // in seconds since Epoch 309 foreach ($tokenFamilies as $family) { 310 $valid_before = ($family['valid_before']['t_s'] === 'never') 311 ? PHP_INT_MAX 312 : $family['valid_before']['t_s']; 313 if ( ($family['kind'] === 'subscription') && 314 ($family['valid_after']['t_s'] < $now) && 315 ($valid_before >= $now) ) { 316 $slug = $family['slug']; 317 $result[$slug] = [ 318 'name' => $family['name'], 319 'label' => $slug, 320 'valid_before_s' => $valid_before, 321 'description' => $family['description'], 322 'description_i18n' => ($family['description_i18n'] ?? NULL), 323 ]; 324 $found = TRUE; 325 } 326 else 327 { 328 $this->logger->info('Token family @slug is not valid right now, skipping it.', ['@slug' => $family['slug']]); 329 } 330 }; // end foreach token family 331 \Drupal::cache()->set($cid, 332 $result, 333 time() + self::CACHE_BACKEND_DATA_SECONDS); 334 return $result; 335 } 336 catch (RequestException $e) { 337 $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 338 $this->logger->error('Failed to obtain list of token families: @message: @body', ['@message' => $e->getMessage(), '@body' => $body_log_fmt ?? 'N/A']); 339 } 340 return $result; 341 } 342 343 344 /** 345 * Gets the list of available currencies. 346 * 347 * @return array 348 * Array of currencies with 'code' (currency code), 'name' and 'label' 349 * and 'step' (typically 0 for JPY or 0.01 for EUR/USD). 350 */ 351 public function getCurrencies() { 352 $cid = 'taler_turnstile:currencies'; 353 if ($cache = \Drupal::cache()->get($cid)) { 354 return $cache->data; 355 } 356 357 $config = \Drupal::config('taler_turnstile.settings'); 358 $payment_backend_url = $config->get('payment_backend_url'); 359 360 if (empty($payment_backend_url)) { 361 $this->logger->error('Taler merchant backend not configured; cannot obtain currency list'); 362 return []; 363 } 364 365 try { 366 // Fetch backend configuration. 367 $http_client = $this->httpClientFactory->fromOptions([ 368 'allow_redirects' => TRUE, 369 'http_errors' => FALSE, 370 'allow_redirects' => TRUE, 371 'timeout' => 5, // seconds 372 ]); 373 374 $config_url = $payment_backend_url . 'config'; 375 $response = $http_client->get($config_url); 376 377 if ($response->getStatusCode() !== 200) { 378 $this->logger->error('Taler merchant backend did not respond; cannot obtain currency list'); 379 return []; 380 } 381 382 $backend_config = json_decode($response->getBody(), TRUE); 383 if (!$backend_config || !is_array($backend_config)) { 384 // Invalid response, fallback to grant_access_on_error setting. 385 $this->logger->error('Taler merchant backend returned invalid /config response; cannot obtain currency list'); 386 return []; 387 } 388 389 if (!isset($backend_config['version']) || !is_string($backend_config['version'])) { 390 $this->logger->error('Taler merchant backend /config response is missing the "version" field; cannot obtain currency list.'); 391 return []; 392 } 393 if (!$this->checkVersion($backend_config['version'])) { 394 // checkVersion() already logged the specific reason. 395 return []; 396 } 397 398 if (! isset($backend_config['currencies'])) 399 { 400 $this->logger->error('Backend returned malformed response for /config'); 401 return []; 402 } 403 404 // Parse and validate each amount in the comma-separated list. 405 $currencies = $backend_config['currencies']; 406 407 $result = array_map(function ($currency) { 408 return [ 409 'code' => $currency['currency'], 410 'name' => $currency['name'], 411 'label' => $currency['alt_unit_names'][0] ?? $currency['id'], 412 'step' => pow(0.1, $currency['num_fractional_input_digits'] ?? 2), 413 ]; 414 }, 415 $currencies 416 ); 417 418 \Drupal::cache()->set($cid, $result, time() + self::CACHE_BACKEND_DATA_SECONDS); 419 return $result; 420 } catch (\Exception $e) { 421 422 // On exception, fall back to grant_access_on_error setting. 423 $this->logger->error('Failed to validate obtain configuration from backend: @error', [ 424 '@error' => $e->getMessage(), 425 ]); 426 return []; 427 } 428 } 429 430 431 /** 432 * Check order status with Taler backend. Used only for diagnostic 433 * code paths; the main paywall flow uses verifyPaidOrder(). 434 * 435 * @param string $order_id 436 * The order ID to check. 437 * 438 * @return array|FALSE 439 * Order status information or FALSE on failure. 440 */ 441 public function checkOrderStatus($order_id) { 442 $config = \Drupal::config('taler_turnstile.settings'); 443 $backend_url = $config->get('payment_backend_url'); 444 $access_token = $config->get('access_token'); 445 446 if (empty($backend_url) || 447 empty($access_token)) { 448 $this->logger->debug('No GNU Taler Turnstile backend configured, cannot check order status!'); 449 return FALSE; 450 } 451 452 try { 453 $http_client = $this->httpClientFactory->fromOptions([ 454 'headers' => [ 455 'Authorization' => 'Bearer ' . $access_token, 456 ], 457 // Do not throw exceptions on 4xx/5xx status codes 458 'http_errors' => false, 459 'allow_redirects' => TRUE, 460 'timeout' => 5, // seconds 461 ]); 462 $response = $http_client->get($backend_url . 'private/orders/' . $order_id); 463 464 $http_status = $response->getStatusCode(); 465 $body = $response->getBody(); 466 $jbody = json_decode($body, TRUE); 467 switch ($http_status) 468 { 469 case 200: 470 $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 471 $this->logger->debug('Got existing contract: @body', ['@body' => $body_log_fmt ?? 'N/A']); 472 // Success, handled below 473 break; 474 case 403: 475 $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your GNU Taler Turnstile configuration!'); 476 return FALSE; 477 case 404: 478 // Order unknown or instance unknown 479 /** @var TalerErrorCode $ec */ 480 $ec = TalerErrorCode::tryFrom ($jbody['code']) ?? TalerErrorCode::TALER_EC_NONE; 481 switch ($ec) 482 { 483 case TalerErrorCode::TALER_EC_NONE: 484 // Protocol violation. Could happen if the backend domain was 485 // taken over by someone else. 486 $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 487 $this->logger->error('Invalid response from merchant backend when trying to obtain order status. Check your GNU Taler Turnstile configuration! @body', ['@body' => $body_log_fmt ?? 'N/A']); 488 return FALSE; 489 case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN: 490 // This could happen if our instance was deleted after the configuration was 491 // checked. Very bad, log serious error. 492 $this->logger->error('Configured instance "@detail" unknown to merchant backend. Check your GNU Taler Turnstile configuration!', ['@detail' => $jbody['detail'] ?? 'N/A']); 493 return FALSE; 494 case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN: 495 // This could happen if the instance owner manually deleted 496 // an order while the customer was looking at the article. 497 $this->logger->warning('Order "@order" disappeared in the backend.', ['@order' => $order_id]); 498 return FALSE; 499 default: 500 $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 501 $this->logger->error('Unexpected error code @ec with HTTP status code @status from Taler merchant backend when trying to get order status: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@detail' => $jbody['detail'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']); 502 return FALSE; 503 } 504 default: 505 // Internal server errors and the like... 506 $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 507 $this->logger->error('Unexpected HTTP status code @status from Taler merchant backend when trying to get order status: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@detail' => $jbody['detail'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']); 508 return FALSE; 509 } 510 511 512 $order_status = $jbody['order_status'] ?? 'unknown'; 513 $subscription_expiration = 0; 514 $subscription_slug = FALSE; 515 $pay_deadline = 0; 516 $paid = FALSE; 517 switch ($order_status) 518 { 519 case 'unpaid': 520 // 'pay_deadline' is only available since v21 rev 1, so for now we 521 // fall back to creation_time + offset. FIXME later! 522 $pay_deadline = $jbody['pay_deadline']['t_s'] ?? 523 (self::ORDER_VALIDITY_SECONDS + $jbody['creation_time']['t_s'] ?? 0); 524 break; 525 case 'claimed': 526 $contract_terms = $jbody['contract_terms']; 527 $pay_deadline = $contract_terms['pay_deadline']['t_s'] ?? 0; 528 break; 529 case 'paid': 530 $paid = TRUE; 531 $contract_terms = $jbody['contract_terms']; 532 $contract_version = $jbody['version'] ?? 0; 533 $now = time(); 534 switch ($contract_version) { 535 case 0: 536 $this->logger->warning('Got unexpected v0 contract version'); 537 break; 538 case 1: 539 $choice_index = $jbody['choice_index'] ?? 0; 540 $token_families = $contract_terms['token_families']; 541 $contract_choice = $contract_terms['choices'][$choice_index]; 542 $outputs = $contract_choice['outputs']; 543 $found = FALSE; 544 foreach ($outputs as $output) { 545 $slug = $output['token_family_slug']; 546 $token_family = $token_families[$slug]; 547 $details = $token_family['details']; 548 if ('subscription' !== $details['class']) { 549 continue; 550 } 551 $keys = $token_family['keys']; 552 foreach ($keys as $key) { 553 $signature_validity_start = $key['signature_validity_start']['t_s']; 554 $signature_validity_end = $key['signature_validity_end']['t_s']; 555 if ( ($signature_validity_start <= $now) && 556 ($signature_validity_end > $now) ) 557 { 558 // Theoretically, one contract could buy multiple 559 // subscriptions. But GNU Taler Turnstile does not 560 // generate such contracts and we do not support 561 // that case here. 562 $subscription_slug = $slug; 563 $subscription_expiration = $signature_validity_end; 564 $found = TRUE; 565 break; 566 } 567 } // end of for each key 568 if ($found) 569 break; 570 } // end of for each output 571 break; 572 default: 573 $this->logger->error('Got unsupported contract version "@version"', ['@version' => $contract_version]); 574 break; 575 } // end switch on contract version 576 break; 577 default: 578 $this->logger->error('Got unexpected order status "@status"', ['@status' => $order_status]); 579 break; 580 } // switch on $order_status 581 return [ 582 'order_id' => $order_id, 583 'paid' => $paid, 584 'subscription_slug' => $subscription_slug, 585 'subscription_expiration' => $subscription_expiration, 586 'order_expiration' => $pay_deadline, 587 ]; 588 } 589 catch (RequestException $e) { 590 // Any kind of error that is outside of the spec. 591 $this->logger->error('Failed to check order status: @message', ['@message' => $e->getMessage()]); 592 return FALSE; 593 } 594 } 595 596 597 /** 598 * Build the request body for a "paivana"-style template 599 * mirroring the prices of the given $price_category. 600 * 601 * @param TurnstilePriceCategory $price_category 602 * The price category to mirror. 603 * @return array|FALSE 604 * Body suitable for POST/PATCH on /private/templates, 605 * or FALSE if the category does not yield a usable set 606 * of payment choices. 607 */ 608 private function buildTemplateBody(TurnstilePriceCategory $price_category) { 609 $subscriptions = $this->getSubscriptions(); 610 $choices = $price_category->getPaymentChoices($subscriptions); 611 if (empty($choices)) { 612 return FALSE; 613 } 614 $description = $price_category->getDescription(); 615 if (empty($description)) { 616 $description = $price_category->label() ?? $price_category->id(); 617 } 618 return [ 619 'template_id' => $price_category->getTemplateId(), 620 'template_description' => $description, 621 'template_contract' => [ 622 'template_type' => 'paivana', 623 'summary' => 'Access to: @' . $price_category->id(), 624 'choices' => $choices, 625 // Limit how long a paywall page is valid; the cookie 626 // we hand out cannot outlive the order. 627 'pay_duration' => [ 'd_us' => self::ORDER_VALIDITY_SECONDS * 1000000 ], 628 'max_pickup_duration' => [ 'd_us' => self::ORDER_VALIDITY_SECONDS * 1000000 ], 629 ], 630 ]; 631 } 632 633 634 /** 635 * Create or update the "paivana"-style template in the merchant 636 * backend that mirrors the prices configured in $price_category. 637 * Performs a POST and falls back to PATCH if the template already 638 * exists. 639 * 640 * @param TurnstilePriceCategory $price_category 641 * The price category to publish as a template. 642 * @return bool 643 * TRUE on success, FALSE on any error 644 */ 645 public function syncTemplate(TurnstilePriceCategory $price_category): bool { 646 $config = \Drupal::config('taler_turnstile.settings'); 647 $backend_url = $config->get('payment_backend_url'); 648 $access_token = $config->get('access_token'); 649 if (empty($backend_url) || empty($access_token)) { 650 $this->logger->debug('No backend, skipping template sync for @id', ['@id' => $price_category->id()]); 651 return FALSE; 652 } 653 $body = $this->buildTemplateBody($price_category); 654 if (FALSE === $body) { 655 $this->logger->info('Price category @id has no usable choices, deleting any existing template', ['@id' => $price_category->id()]); 656 return $this->deleteTemplate($price_category->getTemplateId()); 657 } 658 659 $template_id = $body['template_id']; 660 $http_client = $this->httpClientFactory->fromOptions([ 661 'headers' => [ 662 'Authorization' => 'Bearer ' . $access_token, 663 'Content-Type' => 'application/json', 664 ], 665 'http_errors' => FALSE, 666 'allow_redirects' => TRUE, 667 'timeout' => 5, 668 ]); 669 try { 670 $response = $http_client->post($backend_url . 'private/templates', [ 671 'json' => $body, 672 ]); 673 $http_status = $response->getStatusCode(); 674 if ($http_status === 204) { 675 $this->logger->info('Created template @tid', ['@tid' => $template_id]); 676 return TRUE; 677 } 678 $jbody = json_decode((string) $response->getBody(), TRUE) ?? []; 679 $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 680 if ($http_status === 409) { 681 // Template already exists, fall through to PATCH below. 682 $this->logger->debug('Template @tid already exists, updating via PATCH', ['@tid' => $template_id]); 683 } 684 else { 685 $this->logger->error('Unexpected HTTP status @status creating template @tid: @hint (@detail, #@ec): @body', [ 686 '@status' => $http_status, 687 '@tid' => $template_id, 688 '@hint' => $jbody['hint'] ?? 'N/A', 689 '@detail' => $jbody['detail'] ?? 'N/A', 690 '@ec' => $jbody['code'] ?? 'N/A', 691 '@body' => $body_log_fmt ?? 'N/A', 692 ]); 693 if ($http_status !== 409) { 694 return FALSE; 695 } 696 } 697 698 // PATCH path. Note that PATCH does NOT take template_id in the body. 699 $patch_body = $body; 700 unset($patch_body['template_id']); 701 $response = $http_client->patch( 702 $backend_url . 'private/templates/' . rawurlencode($template_id), 703 ['json' => $patch_body] 704 ); 705 $http_status = $response->getStatusCode(); 706 if ($http_status === 204) { 707 $this->logger->info('Updated template @tid', ['@tid' => $template_id]); 708 return TRUE; 709 } 710 $jbody = json_decode((string) $response->getBody(), TRUE) ?? []; 711 $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 712 $this->logger->error('Unexpected HTTP status @status updating template @tid: @hint (@detail, #@ec): @body', [ 713 '@status' => $http_status, 714 '@tid' => $template_id, 715 '@hint' => $jbody['hint'] ?? 'N/A', 716 '@detail' => $jbody['detail'] ?? 'N/A', 717 '@ec' => $jbody['code'] ?? 'N/A', 718 '@body' => $body_log_fmt ?? 'N/A', 719 ]); 720 return FALSE; 721 } 722 catch (RequestException $e) { 723 $this->logger->error('Failed to sync template @tid: @message', [ 724 '@tid' => $template_id, 725 '@message' => $e->getMessage(), 726 ]); 727 return FALSE; 728 } 729 } 730 731 732 /** 733 * Delete the template with the given ID in the merchant backend. 734 * A 404 from the backend is treated as success, since the desired 735 * end state is "no such template". 736 * 737 * @param string $template_id 738 * The full template ID to delete. 739 * @return bool 740 * TRUE on success or 404, FALSE on any other error 741 */ 742 public function deleteTemplate(string $template_id): bool { 743 $config = \Drupal::config('taler_turnstile.settings'); 744 $backend_url = $config->get('payment_backend_url'); 745 $access_token = $config->get('access_token'); 746 if (empty($backend_url) || empty($access_token)) { 747 $this->logger->debug('No backend, skipping template delete for @tid', ['@tid' => $template_id]); 748 return FALSE; 749 } 750 try { 751 $http_client = $this->httpClientFactory->fromOptions([ 752 'headers' => [ 753 'Authorization' => 'Bearer ' . $access_token, 754 ], 755 'http_errors' => FALSE, 756 'allow_redirects' => TRUE, 757 'timeout' => 5, 758 ]); 759 $response = $http_client->delete( 760 $backend_url . 'private/templates/' . rawurlencode($template_id) 761 ); 762 $http_status = $response->getStatusCode(); 763 if ($http_status === 204 || $http_status === 404) { 764 $this->logger->info('Template @tid removed (HTTP @status)', ['@tid' => $template_id, '@status' => $http_status]); 765 return TRUE; 766 } 767 $jbody = json_decode((string) $response->getBody(), TRUE) ?? []; 768 $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 769 $this->logger->error('Unexpected HTTP status @status deleting template @tid: @hint (@detail, #@ec): @body', [ 770 '@status' => $http_status, 771 '@tid' => $template_id, 772 '@hint' => $jbody['hint'] ?? 'N/A', 773 '@detail' => $jbody['detail'] ?? 'N/A', 774 '@ec' => $jbody['code'] ?? 'N/A', 775 '@body' => $body_log_fmt ?? 'N/A', 776 ]); 777 return FALSE; 778 } 779 catch (RequestException $e) { 780 $this->logger->error('Failed to delete template @tid: @message', [ 781 '@tid' => $template_id, 782 '@message' => $e->getMessage(), 783 ]); 784 return FALSE; 785 } 786 } 787 788 789 /** 790 * Re-publish all known TurnstilePriceCategory templates. 791 * Useful after settings changes that affect the contents 792 * of every category template (e.g. subscription_prices). 793 */ 794 public function syncAllTemplates(): void { 795 try { 796 $categories = \Drupal::entityTypeManager() 797 ->getStorage('taler_turnstile_price_category') 798 ->loadMultiple(); 799 } 800 catch (\Exception $e) { 801 $this->logger->error('Failed to load price categories: @message', ['@message' => $e->getMessage()]); 802 return; 803 } 804 foreach ($categories as $category) { 805 $this->syncTemplate($category); 806 } 807 } 808 809 810 /** 811 * Look up a paid order with the given session ID and verify that 812 * it actually pays for the given $website (fulfillment URL) at 813 * one of the prices listed in @a expected_amounts. 814 * 815 * @param string $order_id 816 * The order ID claimed by the client. 817 * @param string $session_id 818 * The session ID (paivana_id) the client computed. 819 * @param string $website 820 * The fulfillment URL that the contract must reference. 821 * @param array $expected_amounts 822 * Whitelist of acceptable "currency:amount" strings, typically 823 * built from the price category for the node. 824 * @return array|FALSE 825 * On success: ['paid' => TRUE, 'subscription_slug' => ?, 826 * 'subscription_expiration' => ?]. 827 * FALSE on any failure (also logs). 828 */ 829 public function verifyPaidOrder(string $order_id, 830 string $session_id, 831 string $website, 832 array $expected_amounts) { 833 $config = \Drupal::config('taler_turnstile.settings'); 834 $backend_url = $config->get('payment_backend_url'); 835 $access_token = $config->get('access_token'); 836 if (empty($backend_url) || empty($access_token)) { 837 $this->logger->debug('No backend configured, cannot verify order'); 838 return FALSE; 839 } 840 try { 841 $http_client = $this->httpClientFactory->fromOptions([ 842 'headers' => [ 843 'Authorization' => 'Bearer ' . $access_token, 844 ], 845 'http_errors' => FALSE, 846 'allow_redirects' => TRUE, 847 'timeout' => 5, 848 ]); 849 $url = $backend_url . 'private/orders/' . rawurlencode($order_id) 850 . '?session_id=' . rawurlencode($session_id); 851 $response = $http_client->get($url); 852 $http_status = $response->getStatusCode(); 853 $jbody = json_decode((string) $response->getBody(), TRUE) ?? []; 854 if ($http_status !== 200) { 855 $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 856 $this->logger->warning('Unexpected HTTP status @status verifying order @oid: @hint (@detail, #@ec): @body', [ 857 '@status' => $http_status, 858 '@oid' => $order_id, 859 '@hint' => $jbody['hint'] ?? 'N/A', 860 '@detail' => $jbody['detail'] ?? 'N/A', 861 '@ec' => $jbody['code'] ?? 'N/A', 862 '@body' => $body_log_fmt ?? 'N/A', 863 ]); 864 return FALSE; 865 } 866 if (($jbody['order_status'] ?? '') !== 'paid') { 867 $this->logger->info('Order @oid not (yet) paid, status=@s', [ 868 '@oid' => $order_id, 869 '@s' => $jbody['order_status'] ?? 'unknown', 870 ]); 871 return FALSE; 872 } 873 $contract_terms = $jbody['contract_terms'] ?? []; 874 $contract_fulfillment = $contract_terms['fulfillment_url'] ?? ''; 875 if ($contract_fulfillment !== $website) { 876 $this->logger->warning('Paid order @oid is for fulfillment URL "@got" but client claimed "@want"', [ 877 '@oid' => $order_id, 878 '@got' => $contract_fulfillment, 879 '@want' => $website, 880 ]); 881 return FALSE; 882 } 883 // Pull out the paid amount. Contract version 1 stores choices. 884 $contract_version = $jbody['version'] ?? 0; 885 $paid_amount = NULL; 886 $subscription_slug = FALSE; 887 $subscription_expiration = 0; 888 if (1 === $contract_version) { 889 $choice_index = $jbody['choice_index'] ?? 0; 890 $contract_choice = $contract_terms['choices'][$choice_index] ?? []; 891 $paid_amount = $contract_choice['amount'] ?? NULL; 892 // Detect any subscription tokens generated by this purchase. 893 $token_families = $contract_terms['token_families'] ?? []; 894 $outputs = $contract_choice['outputs'] ?? []; 895 $now = time(); 896 foreach ($outputs as $output) { 897 $slug = $output['token_family_slug'] ?? NULL; 898 if (!$slug || !isset($token_families[$slug])) { 899 continue; 900 } 901 $token_family = $token_families[$slug]; 902 if (($token_family['details']['class'] ?? NULL) !== 'subscription') { 903 continue; 904 } 905 foreach ($token_family['keys'] ?? [] as $key) { 906 $start = $key['signature_validity_start']['t_s'] ?? 0; 907 $end = $key['signature_validity_end']['t_s'] ?? 0; 908 if (($start <= $now) && ($end > $now)) { 909 $subscription_slug = $slug; 910 $subscription_expiration = $end; 911 break 2; 912 } 913 } 914 } 915 } 916 else { 917 $this->logger->error('Unsupported contract version @v for order @oid', [ 918 '@v' => $contract_version, 919 '@oid' => $order_id, 920 ]); 921 return FALSE; 922 } 923 if ($paid_amount === NULL) { 924 $this->logger->error('Could not determine paid amount for order @oid', ['@oid' => $order_id]); 925 return FALSE; 926 } 927 if (!in_array($paid_amount, $expected_amounts, TRUE)) { 928 $this->logger->warning('Paid order @oid has amount @got which is not among acceptable amounts (@want) for fulfillment @url', [ 929 '@oid' => $order_id, 930 '@got' => $paid_amount, 931 '@want' => implode(', ', $expected_amounts), 932 '@url' => $website, 933 ]); 934 return FALSE; 935 } 936 return [ 937 'paid' => TRUE, 938 'amount' => $paid_amount, 939 'subscription_slug' => $subscription_slug, 940 'subscription_expiration' => $subscription_expiration, 941 ]; 942 } 943 catch (RequestException $e) { 944 $this->logger->error('Failed to verify order @oid: @message', [ 945 '@oid' => $order_id, 946 '@message' => $e->getMessage(), 947 ]); 948 return FALSE; 949 } 950 } 951 952 953 /** 954 * Build a translation map for all enabled languages. 955 * 956 * @param string $string 957 * The translatable string. 958 * @param array $args 959 * Placeholder replacements. 960 * 961 * @return array 962 * Map of language codes to translated strings. 963 */ 964 private function buildTranslationMap(string $string, array $args = []): array { 965 $translations = []; 966 $language_manager = \Drupal::languageManager(); 967 968 foreach ($language_manager->getLanguages() as $langcode => $language) { 969 $translation = $this->t($string, $args, [ 970 'langcode' => $langcode, 971 ]); 972 $translations[$langcode] = (string) $translation; 973 } 974 return $translations; 975 } 976 977 978 }