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