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