1<?php 2 3/** 4 * This file is part of the FreeDSx LDAP package. 5 * 6 * (c) Chad Sikorra <Chad.Sikorra@gmail.com> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace FreeDSx\Ldap; 13 14use FreeDSx\Ldap\Entry\Attribute; 15use FreeDSx\Ldap\Entry\Dn; 16use FreeDSx\Ldap\Exception\InvalidArgumentException; 17use FreeDSx\Ldap\Exception\UrlParseException; 18use function array_map; 19use function count; 20use function end; 21use function explode; 22use function implode; 23use function key; 24use function ltrim; 25use function parse_url; 26use function preg_match; 27use function reset; 28use function strlen; 29use function strpos; 30use function strtolower; 31 32/** 33 * Represents a LDAP URL. RFC 4516. 34 * 35 * @see https://tools.ietf.org/html/rfc4516 36 * @author Chad Sikorra <Chad.Sikorra@gmail.com> 37 */ 38class LdapUrl 39{ 40 use LdapUrlTrait; 41 42 public const SCOPE_BASE = 'base'; 43 44 public const SCOPE_ONE = 'one'; 45 46 public const SCOPE_SUB = 'sub'; 47 48 /** 49 * @var null|int 50 */ 51 protected $port; 52 53 /** 54 * @var bool 55 */ 56 protected $useSsl = false; 57 58 /** 59 * @var null|string 60 */ 61 protected $host; 62 63 /** 64 * @var null|Dn 65 */ 66 protected $dn; 67 68 /** 69 * @var null|string 70 */ 71 protected $scope; 72 73 /** 74 * @var Attribute[] 75 */ 76 protected $attributes = []; 77 78 /** 79 * @var null|string 80 */ 81 protected $filter; 82 83 /** 84 * @var LdapUrlExtension[] 85 */ 86 protected $extensions = []; 87 88 public function __construct(?string $host = null) 89 { 90 $this->host = $host; 91 } 92 93 /** 94 * @param null|string|Dn $dn 95 * @return $this 96 */ 97 public function setDn($dn) 98 { 99 $this->dn = $dn === null ? $dn : new Dn($dn); 100 101 return $this; 102 } 103 104 public function getDn(): ?Dn 105 { 106 return $this->dn; 107 } 108 109 public function getHost(): ?string 110 { 111 return $this->host; 112 } 113 114 /** 115 * @return $this 116 */ 117 public function setHost(?string $host) 118 { 119 $this->host = $host; 120 121 return $this; 122 } 123 124 /** 125 * @param int|null $port 126 * @return $this 127 */ 128 public function setPort(?int $port) 129 { 130 $this->port = $port; 131 132 return $this; 133 } 134 135 /** 136 * @return int|null 137 */ 138 public function getPort(): ?int 139 { 140 return $this->port; 141 } 142 143 /** 144 * @return null|string 145 */ 146 public function getScope(): ?string 147 { 148 return $this->scope; 149 } 150 151 /** 152 * @param null|string $scope 153 * @return $this 154 * @throws InvalidArgumentException 155 */ 156 public function setScope(?string $scope) 157 { 158 $scope = $scope === null ? $scope : strtolower($scope); 159 if ($scope !== null && !in_array($scope, [self::SCOPE_BASE, self::SCOPE_ONE, self::SCOPE_SUB], true)) { 160 throw new InvalidArgumentException(sprintf( 161 'The scope "%s" is not valid. It must be one of: %s, %s, %s', 162 $scope, 163 self::SCOPE_BASE, 164 self::SCOPE_ONE, 165 self::SCOPE_SUB 166 )); 167 } 168 $this->scope = $scope; 169 170 return $this; 171 } 172 173 /** 174 * @return null|string 175 */ 176 public function getFilter(): ?string 177 { 178 return $this->filter; 179 } 180 181 /** 182 * @param null|string $filter 183 * @return $this 184 */ 185 public function setFilter(?string $filter) 186 { 187 $this->filter = $filter; 188 189 return $this; 190 } 191 192 /** 193 * @return LdapUrlExtension[] 194 */ 195 public function getExtensions(): array 196 { 197 return $this->extensions; 198 } 199 200 /** 201 * @param LdapUrlExtension ...$extensions 202 * @return $this 203 */ 204 public function setExtensions(LdapUrlExtension ...$extensions) 205 { 206 $this->extensions = $extensions; 207 208 return $this; 209 } 210 211 /** 212 * @return Attribute[] 213 */ 214 public function getAttributes(): array 215 { 216 return $this->attributes; 217 } 218 219 /** 220 * @param string|Attribute ...$attributes 221 * @return $this 222 */ 223 public function setAttributes(...$attributes) 224 { 225 $attr = []; 226 foreach ($attributes as $attribute) { 227 $attr[] = $attribute instanceof Attribute ? $attribute : new Attribute($attribute); 228 } 229 $this->attributes = $attr; 230 231 return $this; 232 } 233 234 235 /** 236 * @param bool $useSsl 237 * @return static 238 */ 239 public function setUseSsl(bool $useSsl) 240 { 241 $this->useSsl = $useSsl; 242 243 return $this; 244 } 245 246 /** 247 * @return bool 248 */ 249 public function getUseSsl(): bool 250 { 251 return $this->useSsl; 252 } 253 254 /** 255 * Get the string representation of the URL. 256 * 257 * @return string 258 */ 259 public function toString(): string 260 { 261 $url = ($this->useSsl ? 'ldaps' : 'ldap') . '://' . $this->host; 262 263 if ($this->host !== null && $this->port !== null) { 264 $url .= ':' . $this->port; 265 } 266 267 return $url . '/' . self::encode($this->dn) . $this->getQueryString(); 268 } 269 270 /** 271 * @return string 272 */ 273 public function __toString() 274 { 275 return $this->toString(); 276 } 277 278 /** 279 * Given a string LDAP URL, get its object representation. 280 * 281 * @param string $ldapUrl 282 * @return LdapUrl 283 * @throws UrlParseException 284 * @throws InvalidArgumentException 285 */ 286 public static function parse(string $ldapUrl): LdapUrl 287 { 288 $pieces = self::explodeUrl($ldapUrl); 289 290 $url = new LdapUrl($pieces['host'] ?? null); 291 $url->setUseSsl($pieces['scheme'] === 'ldaps'); 292 $url->setPort($pieces['port'] ?? null); 293 $url->setDn((isset($pieces['path']) && $pieces['path'] !== '/') ? self::decode(ltrim($pieces['path'], '/')) : null); 294 295 $query = explode('?', $pieces['query'] ?? ''); 296 if (count($query) !== 0) { 297 $url->setAttributes(...($query[0] === '' ? [] : explode(',', $query[0]))); 298 $url->setScope(isset($query[1]) && $query[1] !== '' ? $query[1] : null); 299 $url->setFilter(isset($query[2]) && $query[2] !== '' ? self::decode($query[2]) : null); 300 301 $extensions = []; 302 if (isset($query[3]) && $query[3] !== '') { 303 $extensions = array_map(function ($ext) { 304 return LdapUrlExtension::parse($ext); 305 }, explode(',', $query[3])); 306 } 307 $url->setExtensions(...$extensions); 308 } 309 310 return $url; 311 } 312 313 /** 314 * @param string $url 315 * @return array 316 * @throws UrlParseException 317 */ 318 protected static function explodeUrl(string $url): array 319 { 320 $pieces = parse_url($url); 321 322 if ($pieces === false || !isset($pieces['scheme'])) { 323 # 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 324 # for LDAP URLs. In the case of an empty host a client should determine what host to connect to. 325 if (preg_match('/^(ldaps?)\:\/\/\/(.*)$/', $url, $matches) === 0) { 326 throw new UrlParseException(sprintf('The LDAP URL is malformed: %s', $url)); 327 } 328 $query = null; 329 $path = null; 330 331 # Check for query parameters but no path... 332 if (strlen($matches[2]) > 0 && $matches[2][0] === '?') { 333 $query = substr($matches[2], 1); 334 # Check if there are any query parameters and a possible path... 335 } elseif (strpos($matches[2], '?') !== false) { 336 $parts = explode('?', $matches[2], 2); 337 $path = $parts[0]; 338 $query = isset($parts[1]) ? $parts[1] : null; 339 # A path only... 340 } else { 341 $path = $matches[2]; 342 } 343 344 $pieces = [ 345 'scheme' => $matches[1], 346 'path' => $path, 347 'query' => $query, 348 ]; 349 } 350 $pieces['scheme'] = strtolower($pieces['scheme']); 351 352 if (!($pieces['scheme'] === 'ldap' || $pieces['scheme'] === 'ldaps')) { 353 throw new UrlParseException(sprintf('The URL scheme "%s" is not valid. It must be "ldap" or "ldaps".', $pieces['scheme'])); 354 } 355 356 return $pieces; 357 } 358 359 /** 360 * Generate the query part of the URL string representation. Only generates the parts actually used. 361 * 362 * @return string 363 */ 364 protected function getQueryString(): string 365 { 366 $query = []; 367 368 if (count($this->attributes) !== 0) { 369 $query[0] = implode(',', array_map(function ($v) { 370 /** @var $v Attribute */ 371 return self::encode($v->getDescription()); 372 }, $this->attributes)); 373 } 374 if ($this->scope !== null) { 375 $query[1] = self::encode($this->scope); 376 } 377 if ($this->filter !== null) { 378 $query[2] = self::encode($this->filter); 379 } 380 if (count($this->extensions) !== 0) { 381 $query[3] = implode(',', $this->extensions); 382 } 383 384 if (count($query) === 0) { 385 return ''; 386 } 387 388 end($query); 389 $last = key($query); 390 reset($query); 391 392 # This is so we stop at the last query part that was actually set, but also capture cases where the first and 393 # third were set but not the second. 394 $url = ''; 395 for ($i = 0; $i <= $last; $i++) { 396 $url .= '?'; 397 if (isset($query[$i])) { 398 /* @phpstan-ignore-next-line */ 399 $url .= $query[$i]; 400 } 401 } 402 403 return $url; 404 } 405} 406