10b3fd2d3SAndreas Gohr<?php 2*dad993c5SAndreas Gohr 30b3fd2d3SAndreas Gohr/** 40b3fd2d3SAndreas Gohr * This file is part of the FreeDSx LDAP package. 50b3fd2d3SAndreas Gohr * 60b3fd2d3SAndreas Gohr * (c) Chad Sikorra <Chad.Sikorra@gmail.com> 70b3fd2d3SAndreas Gohr * 80b3fd2d3SAndreas Gohr * For the full copyright and license information, please view the LICENSE 90b3fd2d3SAndreas Gohr * file that was distributed with this source code. 100b3fd2d3SAndreas Gohr */ 110b3fd2d3SAndreas Gohr 120b3fd2d3SAndreas Gohrnamespace FreeDSx\Ldap; 130b3fd2d3SAndreas Gohr 140b3fd2d3SAndreas Gohruse FreeDSx\Ldap\Entry\Attribute; 150b3fd2d3SAndreas Gohruse FreeDSx\Ldap\Entry\Dn; 160b3fd2d3SAndreas Gohruse FreeDSx\Ldap\Exception\InvalidArgumentException; 170b3fd2d3SAndreas Gohruse FreeDSx\Ldap\Exception\UrlParseException; 18*dad993c5SAndreas Gohruse function array_map; 19*dad993c5SAndreas Gohruse function count; 20*dad993c5SAndreas Gohruse function end; 21*dad993c5SAndreas Gohruse function explode; 22*dad993c5SAndreas Gohruse function implode; 23*dad993c5SAndreas Gohruse function key; 24*dad993c5SAndreas Gohruse function ltrim; 25*dad993c5SAndreas Gohruse function parse_url; 26*dad993c5SAndreas Gohruse function preg_match; 27*dad993c5SAndreas Gohruse function reset; 28*dad993c5SAndreas Gohruse function strlen; 29*dad993c5SAndreas Gohruse function strpos; 30*dad993c5SAndreas Gohruse function strtolower; 310b3fd2d3SAndreas Gohr 320b3fd2d3SAndreas Gohr/** 330b3fd2d3SAndreas Gohr * Represents a LDAP URL. RFC 4516. 340b3fd2d3SAndreas Gohr * 350b3fd2d3SAndreas Gohr * @see https://tools.ietf.org/html/rfc4516 360b3fd2d3SAndreas Gohr * @author Chad Sikorra <Chad.Sikorra@gmail.com> 370b3fd2d3SAndreas Gohr */ 380b3fd2d3SAndreas Gohrclass LdapUrl 390b3fd2d3SAndreas Gohr{ 400b3fd2d3SAndreas Gohr use LdapUrlTrait; 410b3fd2d3SAndreas Gohr 420b3fd2d3SAndreas Gohr public const SCOPE_BASE = 'base'; 430b3fd2d3SAndreas Gohr 440b3fd2d3SAndreas Gohr public const SCOPE_ONE = 'one'; 450b3fd2d3SAndreas Gohr 460b3fd2d3SAndreas Gohr public const SCOPE_SUB = 'sub'; 470b3fd2d3SAndreas Gohr 480b3fd2d3SAndreas Gohr /** 490b3fd2d3SAndreas Gohr * @var null|int 500b3fd2d3SAndreas Gohr */ 510b3fd2d3SAndreas Gohr protected $port; 520b3fd2d3SAndreas Gohr 530b3fd2d3SAndreas Gohr /** 540b3fd2d3SAndreas Gohr * @var bool 550b3fd2d3SAndreas Gohr */ 560b3fd2d3SAndreas Gohr protected $useSsl = false; 570b3fd2d3SAndreas Gohr 580b3fd2d3SAndreas Gohr /** 590b3fd2d3SAndreas Gohr * @var null|string 600b3fd2d3SAndreas Gohr */ 610b3fd2d3SAndreas Gohr protected $host; 620b3fd2d3SAndreas Gohr 630b3fd2d3SAndreas Gohr /** 640b3fd2d3SAndreas Gohr * @var null|Dn 650b3fd2d3SAndreas Gohr */ 660b3fd2d3SAndreas Gohr protected $dn; 670b3fd2d3SAndreas Gohr 680b3fd2d3SAndreas Gohr /** 690b3fd2d3SAndreas Gohr * @var null|string 700b3fd2d3SAndreas Gohr */ 710b3fd2d3SAndreas Gohr protected $scope; 720b3fd2d3SAndreas Gohr 730b3fd2d3SAndreas Gohr /** 740b3fd2d3SAndreas Gohr * @var Attribute[] 750b3fd2d3SAndreas Gohr */ 760b3fd2d3SAndreas Gohr protected $attributes = []; 770b3fd2d3SAndreas Gohr 780b3fd2d3SAndreas Gohr /** 790b3fd2d3SAndreas Gohr * @var null|string 800b3fd2d3SAndreas Gohr */ 810b3fd2d3SAndreas Gohr protected $filter; 820b3fd2d3SAndreas Gohr 830b3fd2d3SAndreas Gohr /** 840b3fd2d3SAndreas Gohr * @var LdapUrlExtension[] 850b3fd2d3SAndreas Gohr */ 860b3fd2d3SAndreas Gohr protected $extensions = []; 870b3fd2d3SAndreas Gohr 880b3fd2d3SAndreas Gohr public function __construct(?string $host = null) 890b3fd2d3SAndreas Gohr { 900b3fd2d3SAndreas Gohr $this->host = $host; 910b3fd2d3SAndreas Gohr } 920b3fd2d3SAndreas Gohr 930b3fd2d3SAndreas Gohr /** 940b3fd2d3SAndreas Gohr * @param null|string|Dn $dn 950b3fd2d3SAndreas Gohr * @return $this 960b3fd2d3SAndreas Gohr */ 970b3fd2d3SAndreas Gohr public function setDn($dn) 980b3fd2d3SAndreas Gohr { 990b3fd2d3SAndreas Gohr $this->dn = $dn === null ? $dn : new Dn($dn); 1000b3fd2d3SAndreas Gohr 1010b3fd2d3SAndreas Gohr return $this; 1020b3fd2d3SAndreas Gohr } 1030b3fd2d3SAndreas Gohr 1040b3fd2d3SAndreas Gohr public function getDn(): ?Dn 1050b3fd2d3SAndreas Gohr { 1060b3fd2d3SAndreas Gohr return $this->dn; 1070b3fd2d3SAndreas Gohr } 1080b3fd2d3SAndreas Gohr 1090b3fd2d3SAndreas Gohr public function getHost(): ?string 1100b3fd2d3SAndreas Gohr { 1110b3fd2d3SAndreas Gohr return $this->host; 1120b3fd2d3SAndreas Gohr } 1130b3fd2d3SAndreas Gohr 1140b3fd2d3SAndreas Gohr /** 1150b3fd2d3SAndreas Gohr * @return $this 1160b3fd2d3SAndreas Gohr */ 1170b3fd2d3SAndreas Gohr public function setHost(?string $host) 1180b3fd2d3SAndreas Gohr { 1190b3fd2d3SAndreas Gohr $this->host = $host; 1200b3fd2d3SAndreas Gohr 1210b3fd2d3SAndreas Gohr return $this; 1220b3fd2d3SAndreas Gohr } 1230b3fd2d3SAndreas Gohr 1240b3fd2d3SAndreas Gohr /** 1250b3fd2d3SAndreas Gohr * @param int|null $port 1260b3fd2d3SAndreas Gohr * @return $this 1270b3fd2d3SAndreas Gohr */ 1280b3fd2d3SAndreas Gohr public function setPort(?int $port) 1290b3fd2d3SAndreas Gohr { 1300b3fd2d3SAndreas Gohr $this->port = $port; 1310b3fd2d3SAndreas Gohr 1320b3fd2d3SAndreas Gohr return $this; 1330b3fd2d3SAndreas Gohr } 1340b3fd2d3SAndreas Gohr 1350b3fd2d3SAndreas Gohr /** 1360b3fd2d3SAndreas Gohr * @return int|null 1370b3fd2d3SAndreas Gohr */ 1380b3fd2d3SAndreas Gohr public function getPort(): ?int 1390b3fd2d3SAndreas Gohr { 1400b3fd2d3SAndreas Gohr return $this->port; 1410b3fd2d3SAndreas Gohr } 1420b3fd2d3SAndreas Gohr 1430b3fd2d3SAndreas Gohr /** 1440b3fd2d3SAndreas Gohr * @return null|string 1450b3fd2d3SAndreas Gohr */ 1460b3fd2d3SAndreas Gohr public function getScope(): ?string 1470b3fd2d3SAndreas Gohr { 1480b3fd2d3SAndreas Gohr return $this->scope; 1490b3fd2d3SAndreas Gohr } 1500b3fd2d3SAndreas Gohr 1510b3fd2d3SAndreas Gohr /** 1520b3fd2d3SAndreas Gohr * @param null|string $scope 1530b3fd2d3SAndreas Gohr * @return $this 154*dad993c5SAndreas Gohr * @throws InvalidArgumentException 1550b3fd2d3SAndreas Gohr */ 1560b3fd2d3SAndreas Gohr public function setScope(?string $scope) 1570b3fd2d3SAndreas Gohr { 1580b3fd2d3SAndreas Gohr $scope = $scope === null ? $scope : strtolower($scope); 1590b3fd2d3SAndreas Gohr if ($scope !== null && !in_array($scope, [self::SCOPE_BASE, self::SCOPE_ONE, self::SCOPE_SUB], true)) { 1600b3fd2d3SAndreas Gohr throw new InvalidArgumentException(sprintf( 1610b3fd2d3SAndreas Gohr 'The scope "%s" is not valid. It must be one of: %s, %s, %s', 1620b3fd2d3SAndreas Gohr $scope, 1630b3fd2d3SAndreas Gohr self::SCOPE_BASE, 1640b3fd2d3SAndreas Gohr self::SCOPE_ONE, 1650b3fd2d3SAndreas Gohr self::SCOPE_SUB 1660b3fd2d3SAndreas Gohr )); 1670b3fd2d3SAndreas Gohr } 1680b3fd2d3SAndreas Gohr $this->scope = $scope; 1690b3fd2d3SAndreas Gohr 1700b3fd2d3SAndreas Gohr return $this; 1710b3fd2d3SAndreas Gohr } 1720b3fd2d3SAndreas Gohr 1730b3fd2d3SAndreas Gohr /** 1740b3fd2d3SAndreas Gohr * @return null|string 1750b3fd2d3SAndreas Gohr */ 1760b3fd2d3SAndreas Gohr public function getFilter(): ?string 1770b3fd2d3SAndreas Gohr { 1780b3fd2d3SAndreas Gohr return $this->filter; 1790b3fd2d3SAndreas Gohr } 1800b3fd2d3SAndreas Gohr 1810b3fd2d3SAndreas Gohr /** 1820b3fd2d3SAndreas Gohr * @param null|string $filter 1830b3fd2d3SAndreas Gohr * @return $this 1840b3fd2d3SAndreas Gohr */ 1850b3fd2d3SAndreas Gohr public function setFilter(?string $filter) 1860b3fd2d3SAndreas Gohr { 1870b3fd2d3SAndreas Gohr $this->filter = $filter; 1880b3fd2d3SAndreas Gohr 1890b3fd2d3SAndreas Gohr return $this; 1900b3fd2d3SAndreas Gohr } 1910b3fd2d3SAndreas Gohr 1920b3fd2d3SAndreas Gohr /** 1930b3fd2d3SAndreas Gohr * @return LdapUrlExtension[] 1940b3fd2d3SAndreas Gohr */ 1950b3fd2d3SAndreas Gohr public function getExtensions(): array 1960b3fd2d3SAndreas Gohr { 1970b3fd2d3SAndreas Gohr return $this->extensions; 1980b3fd2d3SAndreas Gohr } 1990b3fd2d3SAndreas Gohr 2000b3fd2d3SAndreas Gohr /** 2010b3fd2d3SAndreas Gohr * @param LdapUrlExtension ...$extensions 2020b3fd2d3SAndreas Gohr * @return $this 2030b3fd2d3SAndreas Gohr */ 2040b3fd2d3SAndreas Gohr public function setExtensions(LdapUrlExtension ...$extensions) 2050b3fd2d3SAndreas Gohr { 2060b3fd2d3SAndreas Gohr $this->extensions = $extensions; 2070b3fd2d3SAndreas Gohr 2080b3fd2d3SAndreas Gohr return $this; 2090b3fd2d3SAndreas Gohr } 2100b3fd2d3SAndreas Gohr 2110b3fd2d3SAndreas Gohr /** 2120b3fd2d3SAndreas Gohr * @return Attribute[] 2130b3fd2d3SAndreas Gohr */ 2140b3fd2d3SAndreas Gohr public function getAttributes(): array 2150b3fd2d3SAndreas Gohr { 2160b3fd2d3SAndreas Gohr return $this->attributes; 2170b3fd2d3SAndreas Gohr } 2180b3fd2d3SAndreas Gohr 2190b3fd2d3SAndreas Gohr /** 220*dad993c5SAndreas Gohr * @param string|Attribute ...$attributes 2210b3fd2d3SAndreas Gohr * @return $this 2220b3fd2d3SAndreas Gohr */ 2230b3fd2d3SAndreas Gohr public function setAttributes(...$attributes) 2240b3fd2d3SAndreas Gohr { 2250b3fd2d3SAndreas Gohr $attr = []; 2260b3fd2d3SAndreas Gohr foreach ($attributes as $attribute) { 2270b3fd2d3SAndreas Gohr $attr[] = $attribute instanceof Attribute ? $attribute : new Attribute($attribute); 2280b3fd2d3SAndreas Gohr } 2290b3fd2d3SAndreas Gohr $this->attributes = $attr; 2300b3fd2d3SAndreas Gohr 2310b3fd2d3SAndreas Gohr return $this; 2320b3fd2d3SAndreas Gohr } 2330b3fd2d3SAndreas Gohr 2340b3fd2d3SAndreas Gohr 2350b3fd2d3SAndreas Gohr /** 2360b3fd2d3SAndreas Gohr * @param bool $useSsl 237*dad993c5SAndreas Gohr * @return static 2380b3fd2d3SAndreas Gohr */ 2390b3fd2d3SAndreas Gohr public function setUseSsl(bool $useSsl) 2400b3fd2d3SAndreas Gohr { 2410b3fd2d3SAndreas Gohr $this->useSsl = $useSsl; 2420b3fd2d3SAndreas Gohr 2430b3fd2d3SAndreas Gohr return $this; 2440b3fd2d3SAndreas Gohr } 2450b3fd2d3SAndreas Gohr 2460b3fd2d3SAndreas Gohr /** 2470b3fd2d3SAndreas Gohr * @return bool 2480b3fd2d3SAndreas Gohr */ 2490b3fd2d3SAndreas Gohr public function getUseSsl(): bool 2500b3fd2d3SAndreas Gohr { 2510b3fd2d3SAndreas Gohr return $this->useSsl; 2520b3fd2d3SAndreas Gohr } 2530b3fd2d3SAndreas Gohr 2540b3fd2d3SAndreas Gohr /** 2550b3fd2d3SAndreas Gohr * Get the string representation of the URL. 2560b3fd2d3SAndreas Gohr * 2570b3fd2d3SAndreas Gohr * @return string 2580b3fd2d3SAndreas Gohr */ 2590b3fd2d3SAndreas Gohr public function toString(): string 2600b3fd2d3SAndreas Gohr { 2610b3fd2d3SAndreas Gohr $url = ($this->useSsl ? 'ldaps' : 'ldap') . '://' . $this->host; 2620b3fd2d3SAndreas Gohr 2630b3fd2d3SAndreas Gohr if ($this->host !== null && $this->port !== null) { 2640b3fd2d3SAndreas Gohr $url .= ':' . $this->port; 2650b3fd2d3SAndreas Gohr } 2660b3fd2d3SAndreas Gohr 2670b3fd2d3SAndreas Gohr return $url . '/' . self::encode($this->dn) . $this->getQueryString(); 2680b3fd2d3SAndreas Gohr } 2690b3fd2d3SAndreas Gohr 2700b3fd2d3SAndreas Gohr /** 2710b3fd2d3SAndreas Gohr * @return string 2720b3fd2d3SAndreas Gohr */ 2730b3fd2d3SAndreas Gohr public function __toString() 2740b3fd2d3SAndreas Gohr { 2750b3fd2d3SAndreas Gohr return $this->toString(); 2760b3fd2d3SAndreas Gohr } 2770b3fd2d3SAndreas Gohr 2780b3fd2d3SAndreas Gohr /** 2790b3fd2d3SAndreas Gohr * Given a string LDAP URL, get its object representation. 2800b3fd2d3SAndreas Gohr * 2810b3fd2d3SAndreas Gohr * @param string $ldapUrl 2820b3fd2d3SAndreas Gohr * @return LdapUrl 2830b3fd2d3SAndreas Gohr * @throws UrlParseException 284*dad993c5SAndreas Gohr * @throws InvalidArgumentException 2850b3fd2d3SAndreas Gohr */ 2860b3fd2d3SAndreas Gohr public static function parse(string $ldapUrl): LdapUrl 2870b3fd2d3SAndreas Gohr { 2880b3fd2d3SAndreas Gohr $pieces = self::explodeUrl($ldapUrl); 2890b3fd2d3SAndreas Gohr 2900b3fd2d3SAndreas Gohr $url = new LdapUrl($pieces['host'] ?? null); 2910b3fd2d3SAndreas Gohr $url->setUseSsl($pieces['scheme'] === 'ldaps'); 2920b3fd2d3SAndreas Gohr $url->setPort($pieces['port'] ?? null); 293*dad993c5SAndreas Gohr $url->setDn((isset($pieces['path']) && $pieces['path'] !== '/') ? self::decode(ltrim($pieces['path'], '/')) : null); 2940b3fd2d3SAndreas Gohr 295*dad993c5SAndreas Gohr $query = explode('?', $pieces['query'] ?? ''); 296*dad993c5SAndreas Gohr if (count($query) !== 0) { 297*dad993c5SAndreas Gohr $url->setAttributes(...($query[0] === '' ? [] : explode(',', $query[0]))); 2980b3fd2d3SAndreas Gohr $url->setScope(isset($query[1]) && $query[1] !== '' ? $query[1] : null); 2990b3fd2d3SAndreas Gohr $url->setFilter(isset($query[2]) && $query[2] !== '' ? self::decode($query[2]) : null); 3000b3fd2d3SAndreas Gohr 3010b3fd2d3SAndreas Gohr $extensions = []; 3020b3fd2d3SAndreas Gohr if (isset($query[3]) && $query[3] !== '') { 303*dad993c5SAndreas Gohr $extensions = array_map(function ($ext) { 3040b3fd2d3SAndreas Gohr return LdapUrlExtension::parse($ext); 305*dad993c5SAndreas Gohr }, explode(',', $query[3])); 3060b3fd2d3SAndreas Gohr } 3070b3fd2d3SAndreas Gohr $url->setExtensions(...$extensions); 3080b3fd2d3SAndreas Gohr } 3090b3fd2d3SAndreas Gohr 3100b3fd2d3SAndreas Gohr return $url; 3110b3fd2d3SAndreas Gohr } 3120b3fd2d3SAndreas Gohr 3130b3fd2d3SAndreas Gohr /** 3140b3fd2d3SAndreas Gohr * @param string $url 3150b3fd2d3SAndreas Gohr * @return array 3160b3fd2d3SAndreas Gohr * @throws UrlParseException 3170b3fd2d3SAndreas Gohr */ 3180b3fd2d3SAndreas Gohr protected static function explodeUrl(string $url): array 3190b3fd2d3SAndreas Gohr { 320*dad993c5SAndreas Gohr $pieces = parse_url($url); 3210b3fd2d3SAndreas Gohr 3220b3fd2d3SAndreas Gohr if ($pieces === false || !isset($pieces['scheme'])) { 3230b3fd2d3SAndreas Gohr # We are on our own here if it's an empty host, as parse_url will not treat it as valid, though it is valid 3240b3fd2d3SAndreas Gohr # for LDAP URLs. In the case of an empty host a client should determine what host to connect to. 325*dad993c5SAndreas Gohr if (preg_match('/^(ldaps?)\:\/\/\/(.*)$/', $url, $matches) === 0) { 3260b3fd2d3SAndreas Gohr throw new UrlParseException(sprintf('The LDAP URL is malformed: %s', $url)); 3270b3fd2d3SAndreas Gohr } 3280b3fd2d3SAndreas Gohr $query = null; 3290b3fd2d3SAndreas Gohr $path = null; 3300b3fd2d3SAndreas Gohr 3310b3fd2d3SAndreas Gohr # Check for query parameters but no path... 332*dad993c5SAndreas Gohr if (strlen($matches[2]) > 0 && $matches[2][0] === '?') { 3330b3fd2d3SAndreas Gohr $query = substr($matches[2], 1); 3340b3fd2d3SAndreas Gohr # Check if there are any query parameters and a possible path... 335*dad993c5SAndreas Gohr } elseif (strpos($matches[2], '?') !== false) { 336*dad993c5SAndreas Gohr $parts = explode('?', $matches[2], 2); 3370b3fd2d3SAndreas Gohr $path = $parts[0]; 3380b3fd2d3SAndreas Gohr $query = isset($parts[1]) ? $parts[1] : null; 3390b3fd2d3SAndreas Gohr # A path only... 3400b3fd2d3SAndreas Gohr } else { 3410b3fd2d3SAndreas Gohr $path = $matches[2]; 3420b3fd2d3SAndreas Gohr } 3430b3fd2d3SAndreas Gohr 3440b3fd2d3SAndreas Gohr $pieces = [ 3450b3fd2d3SAndreas Gohr 'scheme' => $matches[1], 3460b3fd2d3SAndreas Gohr 'path' => $path, 3470b3fd2d3SAndreas Gohr 'query' => $query, 3480b3fd2d3SAndreas Gohr ]; 3490b3fd2d3SAndreas Gohr } 350*dad993c5SAndreas Gohr $pieces['scheme'] = strtolower($pieces['scheme']); 3510b3fd2d3SAndreas Gohr 3520b3fd2d3SAndreas Gohr if (!($pieces['scheme'] === 'ldap' || $pieces['scheme'] === 'ldaps')) { 3530b3fd2d3SAndreas Gohr throw new UrlParseException(sprintf('The URL scheme "%s" is not valid. It must be "ldap" or "ldaps".', $pieces['scheme'])); 3540b3fd2d3SAndreas Gohr } 3550b3fd2d3SAndreas Gohr 3560b3fd2d3SAndreas Gohr return $pieces; 3570b3fd2d3SAndreas Gohr } 3580b3fd2d3SAndreas Gohr 3590b3fd2d3SAndreas Gohr /** 3600b3fd2d3SAndreas Gohr * Generate the query part of the URL string representation. Only generates the parts actually used. 3610b3fd2d3SAndreas Gohr * 3620b3fd2d3SAndreas Gohr * @return string 3630b3fd2d3SAndreas Gohr */ 3640b3fd2d3SAndreas Gohr protected function getQueryString(): string 3650b3fd2d3SAndreas Gohr { 3660b3fd2d3SAndreas Gohr $query = []; 3670b3fd2d3SAndreas Gohr 368*dad993c5SAndreas Gohr if (count($this->attributes) !== 0) { 369*dad993c5SAndreas Gohr $query[0] = implode(',', array_map(function ($v) { 3700b3fd2d3SAndreas Gohr /** @var $v Attribute */ 3710b3fd2d3SAndreas Gohr return self::encode($v->getDescription()); 3720b3fd2d3SAndreas Gohr }, $this->attributes)); 3730b3fd2d3SAndreas Gohr } 3740b3fd2d3SAndreas Gohr if ($this->scope !== null) { 3750b3fd2d3SAndreas Gohr $query[1] = self::encode($this->scope); 3760b3fd2d3SAndreas Gohr } 3770b3fd2d3SAndreas Gohr if ($this->filter !== null) { 3780b3fd2d3SAndreas Gohr $query[2] = self::encode($this->filter); 3790b3fd2d3SAndreas Gohr } 380*dad993c5SAndreas Gohr if (count($this->extensions) !== 0) { 381*dad993c5SAndreas Gohr $query[3] = implode(',', $this->extensions); 3820b3fd2d3SAndreas Gohr } 3830b3fd2d3SAndreas Gohr 384*dad993c5SAndreas Gohr if (count($query) === 0) { 3850b3fd2d3SAndreas Gohr return ''; 3860b3fd2d3SAndreas Gohr } 3870b3fd2d3SAndreas Gohr 388*dad993c5SAndreas Gohr end($query); 389*dad993c5SAndreas Gohr $last = key($query); 390*dad993c5SAndreas Gohr reset($query); 3910b3fd2d3SAndreas Gohr 3920b3fd2d3SAndreas Gohr # This is so we stop at the last query part that was actually set, but also capture cases where the first and 3930b3fd2d3SAndreas Gohr # third were set but not the second. 3940b3fd2d3SAndreas Gohr $url = ''; 3950b3fd2d3SAndreas Gohr for ($i = 0; $i <= $last; $i++) { 3960b3fd2d3SAndreas Gohr $url .= '?'; 3970b3fd2d3SAndreas Gohr if (isset($query[$i])) { 398*dad993c5SAndreas Gohr /* @phpstan-ignore-next-line */ 3990b3fd2d3SAndreas Gohr $url .= $query[$i]; 4000b3fd2d3SAndreas Gohr } 4010b3fd2d3SAndreas Gohr } 4020b3fd2d3SAndreas Gohr 4030b3fd2d3SAndreas Gohr return $url; 4040b3fd2d3SAndreas Gohr } 4050b3fd2d3SAndreas Gohr} 406