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