1<?php 2 3/** 4 * @see https://github.com/laminas/laminas-feed for the canonical source repository 5 * @copyright https://github.com/laminas/laminas-feed/blob/master/COPYRIGHT.md 6 * @license https://github.com/laminas/laminas-feed/blob/master/LICENSE.md New BSD License 7 */ 8 9namespace Laminas\Feed\PubSubHubbub; 10 11use DateInterval; 12use DateTime; 13use Laminas\Feed\Uri; 14use Laminas\Http\Request as HttpRequest; 15use Laminas\Stdlib\ArrayUtils; 16use Traversable; 17 18class Subscriber 19{ 20 /** 21 * An array of URLs for all Hub Servers to subscribe/unsubscribe. 22 * 23 * @var array 24 */ 25 protected $hubUrls = []; 26 27 /** 28 * An array of optional parameters to be included in any 29 * (un)subscribe requests. 30 * 31 * @var array 32 */ 33 protected $parameters = []; 34 35 /** 36 * The URL of the topic (Rss or Atom feed) which is the subject of 37 * our current intent to subscribe to/unsubscribe from updates from 38 * the currently configured Hub Servers. 39 * 40 * @var string 41 */ 42 protected $topicUrl = ''; 43 44 /** 45 * The URL Hub Servers must use when communicating with this Subscriber 46 * 47 * @var string 48 */ 49 protected $callbackUrl = ''; 50 51 /** 52 * The number of seconds for which the subscriber would like to have the 53 * subscription active. Defaults to null, i.e. not sent, to setup a 54 * permanent subscription if possible. 55 * 56 * @var int 57 */ 58 protected $leaseSeconds; 59 60 /** 61 * The preferred verification mode (sync or async). By default, this 62 * Subscriber prefers synchronous verification, but is considered 63 * desirable to support asynchronous verification if possible. 64 * 65 * Laminas\Feed\Pubsubhubbub\Subscriber will always send both modes, whose 66 * order of occurrence in the parameter list determines this preference. 67 * 68 * @var string 69 */ 70 protected $preferredVerificationMode = PubSubHubbub::VERIFICATION_MODE_SYNC; 71 72 /** 73 * An array of any errors including keys for 'response', 'hubUrl'. 74 * The response is the actual Laminas\Http\Response object. 75 * 76 * @var array 77 */ 78 protected $errors = []; 79 80 /** 81 * An array of Hub Server URLs for Hubs operating at this time in 82 * asynchronous verification mode. 83 * 84 * @var array 85 */ 86 protected $asyncHubs = []; 87 88 /** 89 * An instance of Laminas\Feed\Pubsubhubbub\Model\SubscriptionPersistence used to background 90 * save any verification tokens associated with a subscription or other. 91 * 92 * @var Model\SubscriptionPersistenceInterface 93 */ 94 protected $storage; 95 96 /** 97 * An array of authentication credentials for HTTP Basic Authentication 98 * if required by specific Hubs. The array is indexed by Hub Endpoint URI 99 * and the value is a simple array of the username and password to apply. 100 * 101 * @var array 102 */ 103 protected $authentications = []; 104 105 /** 106 * Tells the Subscriber to append any subscription identifier to the path 107 * of the base Callback URL. E.g. an identifier "subkey1" would be added 108 * to the callback URL "http://www.example.com/callback" to create a subscription 109 * specific Callback URL of "http://www.example.com/callback/subkey1". 110 * 111 * This is required for all Hubs using the Pubsubhubbub 0.1 Specification. 112 * It should be manually intercepted and passed to the Callback class using 113 * Laminas\Feed\Pubsubhubbub\Subscriber\Callback::setSubscriptionKey(). Will 114 * require a route in the form "callback/:subkey" to allow the parameter be 115 * retrieved from an action using the Laminas\Controller\Action::\getParam() 116 * method. 117 * 118 * @var string 119 */ 120 protected $usePathParameter = false; 121 122 /** 123 * Constructor; accepts an array or Traversable instance to preset 124 * options for the Subscriber without calling all supported setter 125 * methods in turn. 126 * 127 * @param null|array|Traversable $options 128 */ 129 public function __construct($options = null) 130 { 131 if ($options !== null) { 132 $this->setOptions($options); 133 } 134 } 135 136 /** 137 * Process any injected configuration options 138 * 139 * @param array|Traversable $options 140 * @return $this 141 * @throws Exception\InvalidArgumentException 142 */ 143 public function setOptions($options) 144 { 145 if ($options instanceof Traversable) { 146 $options = ArrayUtils::iteratorToArray($options); 147 } 148 149 if (! is_array($options)) { 150 throw new Exception\InvalidArgumentException( 151 'Array or Traversable object expected, got ' . gettype($options) 152 ); 153 } 154 if (array_key_exists('hubUrls', $options)) { 155 $this->addHubUrls($options['hubUrls']); 156 } 157 if (array_key_exists('callbackUrl', $options)) { 158 $this->setCallbackUrl($options['callbackUrl']); 159 } 160 if (array_key_exists('topicUrl', $options)) { 161 $this->setTopicUrl($options['topicUrl']); 162 } 163 if (array_key_exists('storage', $options)) { 164 $this->setStorage($options['storage']); 165 } 166 if (array_key_exists('leaseSeconds', $options)) { 167 $this->setLeaseSeconds($options['leaseSeconds']); 168 } 169 if (array_key_exists('parameters', $options)) { 170 $this->setParameters($options['parameters']); 171 } 172 if (array_key_exists('authentications', $options)) { 173 $this->addAuthentications($options['authentications']); 174 } 175 if (array_key_exists('usePathParameter', $options)) { 176 $this->usePathParameter($options['usePathParameter']); 177 } 178 if (array_key_exists('preferredVerificationMode', $options)) { 179 $this->setPreferredVerificationMode( 180 $options['preferredVerificationMode'] 181 ); 182 } 183 return $this; 184 } 185 186 /** 187 * Set the topic URL (RSS or Atom feed) to which the intended (un)subscribe 188 * event will relate 189 * 190 * @param string $url 191 * @return $this 192 * @throws Exception\InvalidArgumentException 193 */ 194 public function setTopicUrl($url) 195 { 196 if (empty($url) || ! is_string($url) || ! Uri::factory($url)->isValid()) { 197 throw new Exception\InvalidArgumentException( 198 'Invalid parameter "url" of "' . $url . '" must be a non-empty string and a valid URL' 199 ); 200 } 201 $this->topicUrl = $url; 202 return $this; 203 } 204 205 /** 206 * Set the topic URL (RSS or Atom feed) to which the intended (un)subscribe 207 * event will relate 208 * 209 * @return string 210 * @throws Exception\RuntimeException 211 */ 212 public function getTopicUrl() 213 { 214 if (empty($this->topicUrl)) { 215 throw new Exception\RuntimeException( 216 'A valid Topic (RSS or Atom feed) URL MUST be set before attempting any operation' 217 ); 218 } 219 return $this->topicUrl; 220 } 221 222 /** 223 * Set the number of seconds for which any subscription will remain valid 224 * 225 * @param int $seconds 226 * @return $this 227 * @throws Exception\InvalidArgumentException 228 */ 229 public function setLeaseSeconds($seconds) 230 { 231 $seconds = intval($seconds); 232 if ($seconds <= 0) { 233 throw new Exception\InvalidArgumentException( 234 'Expected lease seconds must be an integer greater than zero' 235 ); 236 } 237 $this->leaseSeconds = $seconds; 238 return $this; 239 } 240 241 /** 242 * Get the number of lease seconds on subscriptions 243 * 244 * @return int 245 */ 246 public function getLeaseSeconds() 247 { 248 return $this->leaseSeconds; 249 } 250 251 /** 252 * Set the callback URL to be used by Hub Servers when communicating with 253 * this Subscriber 254 * 255 * @param string $url 256 * @return $this 257 * @throws Exception\InvalidArgumentException 258 */ 259 public function setCallbackUrl($url) 260 { 261 if (empty($url) || ! is_string($url) || ! Uri::factory($url)->isValid()) { 262 throw new Exception\InvalidArgumentException( 263 'Invalid parameter "url" of "' . $url . '" must be a non-empty string and a valid URL' 264 ); 265 } 266 $this->callbackUrl = $url; 267 return $this; 268 } 269 270 /** 271 * Get the callback URL to be used by Hub Servers when communicating with 272 * this Subscriber 273 * 274 * @return string 275 * @throws Exception\RuntimeException 276 */ 277 public function getCallbackUrl() 278 { 279 if (empty($this->callbackUrl)) { 280 throw new Exception\RuntimeException( 281 'A valid Callback URL MUST be set before attempting any operation' 282 ); 283 } 284 return $this->callbackUrl; 285 } 286 287 /** 288 * Set preferred verification mode (sync or async). By default, this 289 * Subscriber prefers synchronous verification, but does support 290 * asynchronous if that's the Hub Server's utilised mode. 291 * 292 * Laminas\Feed\Pubsubhubbub\Subscriber will always send both modes, whose 293 * order of occurrence in the parameter list determines this preference. 294 * 295 * @param string $mode Should be 'sync' or 'async' 296 * @return $this 297 * @throws Exception\InvalidArgumentException 298 */ 299 public function setPreferredVerificationMode($mode) 300 { 301 if ($mode !== PubSubHubbub::VERIFICATION_MODE_SYNC 302 && $mode !== PubSubHubbub::VERIFICATION_MODE_ASYNC 303 ) { 304 throw new Exception\InvalidArgumentException( 305 'Invalid preferred mode specified: "' . $mode . '" but should be one of' 306 . ' Laminas\Feed\Pubsubhubbub::VERIFICATION_MODE_SYNC or' 307 . ' Laminas\Feed\Pubsubhubbub::VERIFICATION_MODE_ASYNC' 308 ); 309 } 310 $this->preferredVerificationMode = $mode; 311 return $this; 312 } 313 314 /** 315 * Get preferred verification mode (sync or async). 316 * 317 * @return string 318 */ 319 public function getPreferredVerificationMode() 320 { 321 return $this->preferredVerificationMode; 322 } 323 324 /** 325 * Add a Hub Server URL supported by Publisher 326 * 327 * @param string $url 328 * @return $this 329 * @throws Exception\InvalidArgumentException 330 */ 331 public function addHubUrl($url) 332 { 333 if (empty($url) || ! is_string($url) || ! Uri::factory($url)->isValid()) { 334 throw new Exception\InvalidArgumentException( 335 'Invalid parameter "url" of "' . $url . '" must be a non-empty string and a valid URL' 336 ); 337 } 338 $this->hubUrls[] = $url; 339 return $this; 340 } 341 342 /** 343 * Add an array of Hub Server URLs supported by Publisher 344 * 345 * @return $this 346 */ 347 public function addHubUrls(array $urls) 348 { 349 foreach ($urls as $url) { 350 $this->addHubUrl($url); 351 } 352 return $this; 353 } 354 355 /** 356 * Remove a Hub Server URL 357 * 358 * @param string $url 359 * @return $this 360 */ 361 public function removeHubUrl($url) 362 { 363 if (! in_array($url, $this->getHubUrls())) { 364 return $this; 365 } 366 $key = array_search($url, $this->hubUrls); 367 unset($this->hubUrls[$key]); 368 return $this; 369 } 370 371 /** 372 * Return an array of unique Hub Server URLs currently available 373 * 374 * @return array 375 */ 376 public function getHubUrls() 377 { 378 $this->hubUrls = array_unique($this->hubUrls); 379 return $this->hubUrls; 380 } 381 382 /** 383 * Add authentication credentials for a given URL 384 * 385 * @param string $url 386 * @return $this 387 * @throws Exception\InvalidArgumentException 388 */ 389 public function addAuthentication($url, array $authentication) 390 { 391 if (empty($url) || ! is_string($url) || ! Uri::factory($url)->isValid()) { 392 throw new Exception\InvalidArgumentException( 393 'Invalid parameter "url" of "' . $url . '" must be a non-empty string and a valid URL' 394 ); 395 } 396 $this->authentications[$url] = $authentication; 397 return $this; 398 } 399 400 /** 401 * Add authentication credentials for hub URLs 402 * 403 * @return $this 404 */ 405 public function addAuthentications(array $authentications) 406 { 407 foreach ($authentications as $url => $authentication) { 408 $this->addAuthentication($url, $authentication); 409 } 410 return $this; 411 } 412 413 /** 414 * Get all hub URL authentication credentials 415 * 416 * @return array 417 */ 418 public function getAuthentications() 419 { 420 return $this->authentications; 421 } 422 423 /** 424 * Set flag indicating whether or not to use a path parameter 425 * 426 * @param bool $bool 427 * @return $this 428 */ 429 public function usePathParameter($bool = true) 430 { 431 $this->usePathParameter = $bool; 432 return $this; 433 } 434 435 /** 436 * Add an optional parameter to the (un)subscribe requests 437 * 438 * @param string $name 439 * @param null|string $value 440 * @return $this 441 * @throws Exception\InvalidArgumentException 442 */ 443 public function setParameter($name, $value = null) 444 { 445 if (is_array($name)) { 446 $this->setParameters($name); 447 return $this; 448 } 449 if (empty($name) || ! is_string($name)) { 450 throw new Exception\InvalidArgumentException( 451 'Invalid parameter "name" of "' . $name . '" must be a non-empty string' 452 ); 453 } 454 if ($value === null) { 455 $this->removeParameter($name); 456 return $this; 457 } 458 if (empty($value) || (! is_string($value) && $value !== null)) { 459 throw new Exception\InvalidArgumentException( 460 'Invalid parameter "value" of "' . $value . '" must be a non-empty string' 461 ); 462 } 463 $this->parameters[$name] = $value; 464 return $this; 465 } 466 467 /** 468 * Add an optional parameter to the (un)subscribe requests 469 * 470 * @return $this 471 */ 472 public function setParameters(array $parameters) 473 { 474 foreach ($parameters as $name => $value) { 475 $this->setParameter($name, $value); 476 } 477 return $this; 478 } 479 480 /** 481 * Remove an optional parameter for the (un)subscribe requests 482 * 483 * @param string $name 484 * @return $this 485 * @throws Exception\InvalidArgumentException 486 */ 487 public function removeParameter($name) 488 { 489 if (empty($name) || ! is_string($name)) { 490 throw new Exception\InvalidArgumentException( 491 'Invalid parameter "name" of "' . $name . '" must be a non-empty string' 492 ); 493 } 494 if (array_key_exists($name, $this->parameters)) { 495 unset($this->parameters[$name]); 496 } 497 return $this; 498 } 499 500 /** 501 * Return an array of optional parameters for (un)subscribe requests 502 * 503 * @return array 504 */ 505 public function getParameters() 506 { 507 return $this->parameters; 508 } 509 510 /** 511 * Sets an instance of Laminas\Feed\Pubsubhubbub\Model\SubscriptionPersistence used to background 512 * save any verification tokens associated with a subscription or other. 513 * 514 * @return $this 515 */ 516 public function setStorage(Model\SubscriptionPersistenceInterface $storage) 517 { 518 $this->storage = $storage; 519 return $this; 520 } 521 522 /** 523 * Gets an instance of Laminas\Feed\Pubsubhubbub\Storage\StoragePersistence used 524 * to background save any verification tokens associated with a subscription 525 * or other. 526 * 527 * @return Model\SubscriptionPersistenceInterface 528 * @throws Exception\RuntimeException 529 */ 530 public function getStorage() 531 { 532 if ($this->storage === null) { 533 throw new Exception\RuntimeException('No storage vehicle has been set.'); 534 } 535 return $this->storage; 536 } 537 538 /** 539 * Subscribe to one or more Hub Servers using the stored Hub URLs 540 * for the given Topic URL (RSS or Atom feed) 541 * 542 * @return void 543 */ 544 public function subscribeAll() 545 { 546 $this->_doRequest('subscribe'); 547 } 548 549 /** 550 * Unsubscribe from one or more Hub Servers using the stored Hub URLs 551 * for the given Topic URL (RSS or Atom feed) 552 * 553 * @return void 554 */ 555 public function unsubscribeAll() 556 { 557 $this->_doRequest('unsubscribe'); 558 } 559 560 /** 561 * Returns a boolean indicator of whether the notifications to Hub 562 * Servers were ALL successful. If even one failed, FALSE is returned. 563 * 564 * @return bool 565 */ 566 public function isSuccess() 567 { 568 return ! $this->errors; 569 } 570 571 /** 572 * Return an array of errors met from any failures, including keys: 573 * 'response' => the Laminas\Http\Response object from the failure 574 * 'hubUrl' => the URL of the Hub Server whose notification failed 575 * 576 * @return array 577 */ 578 public function getErrors() 579 { 580 return $this->errors; 581 } 582 583 /** 584 * Return an array of Hub Server URLs who returned a response indicating 585 * operation in Asynchronous Verification Mode, i.e. they will not confirm 586 * any (un)subscription immediately but at a later time (Hubs may be 587 * doing this as a batch process when load balancing) 588 * 589 * @return array 590 */ 591 public function getAsyncHubs() 592 { 593 return $this->asyncHubs; 594 } 595 596 /** 597 * Executes an (un)subscribe request 598 * 599 * @param string $mode 600 * @return void 601 * @throws Exception\RuntimeException 602 */ 603 // @codingStandardsIgnoreStart 604 protected function _doRequest($mode) 605 { 606 // @codingStandardsIgnoreEnd 607 $client = $this->_getHttpClient(); 608 $hubs = $this->getHubUrls(); 609 if (empty($hubs)) { 610 throw new Exception\RuntimeException( 611 'No Hub Server URLs have been set so no subscriptions can be attempted' 612 ); 613 } 614 $this->errors = []; 615 $this->asyncHubs = []; 616 foreach ($hubs as $url) { 617 if (array_key_exists($url, $this->authentications)) { 618 $auth = $this->authentications[$url]; 619 $client->setAuth($auth[0], $auth[1]); 620 } 621 $client->setUri($url); 622 $client->setRawBody($params = $this->_getRequestParameters($url, $mode)); 623 $response = $client->send(); 624 if ($response->getStatusCode() !== 204 625 && $response->getStatusCode() !== 202 626 ) { 627 $this->errors[] = [ 628 'response' => $response, 629 'hubUrl' => $url, 630 ]; 631 632 /** 633 * At first I thought it was needed, but the backend storage will 634 * allow tracking async without any user interference. It's left 635 * here in case the user is interested in knowing what Hubs 636 * are using async verification modes so they may update Models and 637 * move these to asynchronous processes. 638 */ 639 } elseif ($response->getStatusCode() == 202) { 640 $this->asyncHubs[] = [ 641 'response' => $response, 642 'hubUrl' => $url, 643 ]; 644 } 645 } 646 } 647 648 /** 649 * Get a basic prepared HTTP client for use 650 * 651 * @return \Laminas\Http\Client 652 */ 653 // @codingStandardsIgnoreStart 654 protected function _getHttpClient() 655 { 656 // @codingStandardsIgnoreEnd 657 $client = PubSubHubbub::getHttpClient(); 658 $client->setMethod(HttpRequest::METHOD_POST); 659 $client->setOptions([ 660 'useragent' => 'Laminas_Feed_Pubsubhubbub_Subscriber/' . Version::VERSION, 661 ]); 662 return $client; 663 } 664 665 /** 666 * Return a list of standard protocol/optional parameters for addition to 667 * client's POST body that are specific to the current Hub Server URL 668 * 669 * @param string $hubUrl 670 * @param string $mode 671 * @return string 672 * @throws Exception\InvalidArgumentException 673 */ 674 // @codingStandardsIgnoreStart 675 protected function _getRequestParameters($hubUrl, $mode) 676 { 677 // @codingStandardsIgnoreEnd 678 if (! in_array($mode, ['subscribe', 'unsubscribe'])) { 679 throw new Exception\InvalidArgumentException( 680 'Invalid mode specified: "' . $mode . '" which should have been "subscribe" or "unsubscribe"' 681 ); 682 } 683 684 $params = [ 685 'hub.mode' => $mode, 686 'hub.topic' => $this->getTopicUrl(), 687 ]; 688 689 if ($this->getPreferredVerificationMode() === PubSubHubbub::VERIFICATION_MODE_SYNC) { 690 $vmodes = [ 691 PubSubHubbub::VERIFICATION_MODE_SYNC, 692 PubSubHubbub::VERIFICATION_MODE_ASYNC, 693 ]; 694 } else { 695 $vmodes = [ 696 PubSubHubbub::VERIFICATION_MODE_ASYNC, 697 PubSubHubbub::VERIFICATION_MODE_SYNC, 698 ]; 699 } 700 $params['hub.verify'] = []; 701 foreach ($vmodes as $vmode) { 702 $params['hub.verify'][] = $vmode; 703 } 704 705 /** 706 * Establish a persistent verify_token and attach key to callback 707 * URL's path/query_string 708 */ 709 $key = $this->_generateSubscriptionKey($params, $hubUrl); 710 $token = $this->_generateVerifyToken(); 711 $params['hub.verify_token'] = $token; 712 713 // Note: query string only usable with PuSH 0.2 Hubs 714 if (! $this->usePathParameter) { 715 $params['hub.callback'] = $this->getCallbackUrl() 716 . '?xhub.subscription=' . PubSubHubbub::urlencode($key); 717 } else { 718 $params['hub.callback'] = rtrim($this->getCallbackUrl(), '/') 719 . '/' . PubSubHubbub::urlencode($key); 720 } 721 if ($mode === 'subscribe' && $this->getLeaseSeconds() !== null) { 722 $params['hub.lease_seconds'] = $this->getLeaseSeconds(); 723 } 724 725 // hub.secret not currently supported 726 $optParams = $this->getParameters(); 727 foreach ($optParams as $name => $value) { 728 $params[$name] = $value; 729 } 730 731 // store subscription to storage 732 $now = new DateTime(); 733 $expires = null; 734 if (isset($params['hub.lease_seconds'])) { 735 $expires = $now->add(new DateInterval('PT' . $params['hub.lease_seconds'] . 'S')) 736 ->format('Y-m-d H:i:s'); 737 } 738 $data = [ 739 'id' => $key, 740 'topic_url' => $params['hub.topic'], 741 'hub_url' => $hubUrl, 742 'created_time' => $now->format('Y-m-d H:i:s'), 743 'lease_seconds' => $params['hub.lease_seconds'], 744 'verify_token' => hash('sha256', $params['hub.verify_token']), 745 'secret' => null, 746 'expiration_time' => $expires, 747 // @codingStandardsIgnoreStart 748 'subscription_state' => ($mode == 'unsubscribe') ? PubSubHubbub::SUBSCRIPTION_TODELETE : PubSubHubbub::SUBSCRIPTION_NOTVERIFIED, 749 // @codingStandardsIgnoreEnd 750 ]; 751 $this->getStorage()->setSubscription($data); 752 753 return $this->_toByteValueOrderedString( 754 $this->_urlEncode($params) 755 ); 756 } 757 758 /** 759 * Simple helper to generate a verification token used in (un)subscribe 760 * requests to a Hub Server. Follows no particular method, which means 761 * it might be improved/changed in future. 762 * 763 * @return string 764 */ 765 // @codingStandardsIgnoreStart 766 protected function _generateVerifyToken() 767 { 768 // @codingStandardsIgnoreEnd 769 if (! empty($this->testStaticToken)) { 770 return $this->testStaticToken; 771 } 772 return uniqid(rand(), true) . time(); 773 } 774 775 /** 776 * Simple helper to generate a verification token used in (un)subscribe 777 * requests to a Hub Server. 778 * 779 * @param array $params 780 * @param string $hubUrl The Hub Server URL for which this token will apply 781 * @return string 782 */ 783 // @codingStandardsIgnoreStart 784 protected function _generateSubscriptionKey(array $params, $hubUrl) 785 { 786 // @codingStandardsIgnoreEnd 787 $keyBase = $params['hub.topic'] . $hubUrl; 788 $key = md5($keyBase); 789 790 return $key; 791 } 792 793 /** 794 * URL Encode an array of parameters 795 * 796 * @param array $params 797 * @return array 798 */ 799 // @codingStandardsIgnoreStart 800 protected function _urlEncode(array $params) 801 { 802 // @codingStandardsIgnoreEnd 803 $encoded = []; 804 foreach ($params as $key => $value) { 805 if (is_array($value)) { 806 $ekey = PubSubHubbub::urlencode($key); 807 $encoded[$ekey] = []; 808 foreach ($value as $duplicateKey) { 809 $encoded[$ekey][] = PubSubHubbub::urlencode($duplicateKey); 810 } 811 } else { 812 $encoded[PubSubHubbub::urlencode($key)] = PubSubHubbub::urlencode($value); 813 } 814 } 815 return $encoded; 816 } 817 818 /** 819 * Order outgoing parameters 820 * 821 * @param array $params 822 * @return string 823 */ 824 // @codingStandardsIgnoreStart 825 protected function _toByteValueOrderedString(array $params) 826 { 827 // @codingStandardsIgnoreEnd 828 $return = []; 829 uksort($params, 'strnatcmp'); 830 foreach ($params as $key => $value) { 831 if (is_array($value)) { 832 foreach ($value as $keyduplicate) { 833 $return[] = $key . '=' . $keyduplicate; 834 } 835 } else { 836 $return[] = $key . '=' . $value; 837 } 838 } 839 return implode('&', $return); 840 } 841 842 /** 843 * This is STRICTLY for testing purposes only... 844 */ 845 protected $testStaticToken; 846 847 final public function setTestStaticToken(string $token) 848 { 849 $this->testStaticToken = (string) $token; 850 } 851} 852