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\Search;
12
13use FreeDSx\Ldap\Control\Ad\DirSyncRequestControl;
14use FreeDSx\Ldap\Control\Ad\DirSyncResponseControl;
15use FreeDSx\Ldap\Control\Control;
16use FreeDSx\Ldap\Controls;
17use FreeDSx\Ldap\Entry\Entries;
18use FreeDSx\Ldap\Exception\RuntimeException;
19use FreeDSx\Ldap\LdapClient;
20use FreeDSx\Ldap\Operation\Request\SearchRequest;
21use FreeDSx\Ldap\Operation\Response\SearchResponse;
22use FreeDSx\Ldap\Protocol\LdapMessageResponse;
23use FreeDSx\Ldap\Search\Filter\FilterInterface;
24
25/**
26 * Provides a simple wrapper around DirSync for Active Directory.
27 *
28 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
29 */
30class DirSync
31{
32    /**
33     * @var DirSyncResponseControl|null
34     */
35    protected $lastResponse;
36
37    /**
38     * @var SearchRequest
39     */
40    protected $search;
41
42    /**
43     * @var string|null
44     */
45    protected $namingContext;
46
47    /**
48     * @var bool
49     */
50    protected $incrementalValues = true;
51
52    /**
53     * @var bool
54     */
55    protected $objectSecurity = false;
56
57    /**
58     * @var bool
59     */
60    protected $ancestorFirstOrder = false;
61
62    /**
63     * @var null|string
64     */
65    protected $defaultRootNc;
66
67    /**
68     * @var LdapClient
69     */
70    protected $client;
71
72    /**
73     * @var DirSyncRequestControl
74     */
75    protected $dirSyncRequest;
76
77    /**
78     * @param LdapClient $client
79     * @param string|null $namingContext
80     * @param FilterInterface|null $filter
81     * @param mixed ...$attributes
82     */
83    public function __construct(LdapClient $client, ?string $namingContext = null, ?FilterInterface $filter = null, ...$attributes)
84    {
85        $this->client = $client;
86        $this->namingContext = $namingContext;
87        $this->dirSyncRequest = Controls::dirSync();
88        $this->search = (new SearchRequest($filter ?? Filters::present('objectClass'), ...$attributes));
89    }
90
91    /**
92     * A convenience method to easily watch for changes with an anonymous function. The anonymous function will be passed
93     * two arguments:
94     *
95     *     - The Entries object containing the changes.
96     *     - A boolean value indicating whether or not the entries are part of the initial sync (the initial sync returns
97     *       all entries matching the filter).
98     *
99     * An optional second argument then determines how many seconds to wait between checking for changes.
100     *
101     * @param \Closure $handler An anonymous function to pass results to.
102     * @param int $checkInterval How often to check for changes (in seconds).
103     * @throws \FreeDSx\Ldap\Exception\OperationException
104     */
105    public function watch(\Closure $handler, int $checkInterval = 10): void
106    {
107        $handler($this->getChanges(), true);
108        while ($this->hasChanges()) {
109            $handler($this->getChanges(), true);
110        }
111
112        while (true) {
113            sleep($checkInterval);
114            $entries = $this->getChanges();
115            if ($entries->count() === 0) {
116                continue;
117            }
118            $handler($entries, false);
119            while ($this->hasChanges()) {
120                $handler($this->getChanges(), false);
121            }
122        }
123    }
124
125    /**
126     * Check whether or not there are more changes to receive.
127     *
128     * @return bool
129     */
130    public function hasChanges(): bool
131    {
132        if ($this->lastResponse === null) {
133            return false;
134        }
135
136        return $this->lastResponse->hasMoreResults();
137    }
138
139    /**
140     * Get the changes as entries. This may be empty if there are no changes since the last query. This should be
141     * followed with a hasChanges() call to determine if more changes are still available.
142     *
143     * @return Entries
144     * @throws \FreeDSx\Ldap\Exception\OperationException
145     */
146    public function getChanges(): Entries
147    {
148        /** @var LdapMessageResponse $response */
149        $response = $this->client->send($this->getSearchRequest(), $this->getDirSyncControl());
150        $lastResponse = $response->controls()->get(Control::OID_DIR_SYNC);
151        if ($lastResponse === null || !$lastResponse instanceof DirSyncResponseControl) {
152            throw new RuntimeException('Expected a DirSync control in the response, but none was received.');
153        }
154        $this->lastResponse = $lastResponse;
155        $this->dirSyncRequest->setCookie($this->lastResponse->getCookie());
156        /** @var SearchResponse $searchResponse */
157        $searchResponse = $response->getResponse();
158
159        return $searchResponse->getEntries();
160    }
161
162    /**
163     * The attributes to return from the DirSync search.
164     *
165     * @param mixed ...$attributes
166     * @return DirSync
167     */
168    public function selectAttributes(...$attributes)
169    {
170        $this->search->select(...$attributes);
171
172        return $this;
173    }
174
175    /**
176     * A specific DirSync cookie to use. For example, this could be a cookie from a previous DirSync request, assuming
177     * the server still thinks it's valid.
178     *
179     * @param string $cookie
180     * @return $this
181     */
182    public function useCookie(string $cookie)
183    {
184        $this->dirSyncRequest->setCookie($cookie);
185
186        return $this;
187    }
188
189    /**
190     * The naming context to run the DirSync against. This MUST be a root naming context.
191     *
192     * @param string|null $namingContext
193     * @return $this
194     */
195    public function useNamingContext(?string $namingContext)
196    {
197        $this->namingContext = $namingContext;
198
199        return $this;
200    }
201
202    /**
203     * The LDAP filter to limit the results to.
204     *
205     * @param FilterInterface $filter
206     * @return $this
207     */
208    public function useFilter(FilterInterface $filter)
209    {
210        $this->search->setFilter($filter);
211
212        return $this;
213    }
214
215    /**
216     * Whether or not to return only incremental changes on a multivalued attribute that has changed.
217     *
218     * @param bool $incrementalValues
219     * @return $this
220     */
221    public function useIncrementalValues(bool $incrementalValues = true)
222    {
223        $this->incrementalValues = $incrementalValues;
224
225        return $this;
226    }
227
228    /**
229     * Whether or not to only retrieve objects and attributes that are accessible to the client.
230     *
231     * @param bool $objectSecurity
232     * @return $this
233     */
234    public function useObjectSecurity(bool $objectSecurity = true)
235    {
236        $this->objectSecurity = $objectSecurity;
237
238        return $this;
239    }
240
241    /**
242     * Whether or not the server should return parent objects before child objects.
243     *
244     * @param bool $ancestorFirstOrder
245     * @return $this
246     */
247    public function useAncestorFirstOrder(bool $ancestorFirstOrder = true)
248    {
249        $this->ancestorFirstOrder = $ancestorFirstOrder;
250
251        return $this;
252    }
253
254    /**
255     * Get the cookie currently in use.
256     *
257     * @return string
258     */
259    public function getCookie(): string
260    {
261        return $this->dirSyncRequest->getCookie();
262    }
263
264    /**
265     * @return SearchRequest
266     * @throws \FreeDSx\Ldap\Exception\OperationException
267     */
268    protected function getSearchRequest(): SearchRequest
269    {
270        $this->search->base($this->namingContext ?? $this->getDefaultRootNc());
271
272        return $this->search;
273    }
274
275    /**
276     * @return DirSyncRequestControl
277     */
278    protected function getDirSyncControl(): DirSyncRequestControl
279    {
280        $flags = 0;
281        if ($this->incrementalValues) {
282            $flags |= DirSyncRequestControl::FLAG_INCREMENTAL_VALUES;
283        }
284        if ($this->ancestorFirstOrder) {
285            $flags |= DirSyncRequestControl::FLAG_ANCESTORS_FIRST_ORDER;
286        }
287        if ($this->objectSecurity) {
288            $flags |= DirSyncRequestControl::FLAG_OBJECT_SECURITY;
289        }
290        $this->dirSyncRequest->setFlags($flags);
291
292        return $this->dirSyncRequest;
293    }
294
295    /**
296     * @return string|null
297     * @throws \FreeDSx\Ldap\Exception\OperationException
298     */
299    protected function getDefaultRootNc()
300    {
301        if ($this->defaultRootNc === null) {
302            $this->defaultRootNc = (string) $this->client->readOrFail('', ['defaultNamingContext'])->get('defaultNamingContext');
303        }
304        if ($this->defaultRootNc === '') {
305            throw new RuntimeException('Unable to determine the root naming context automatically.');
306        }
307
308        return $this->defaultRootNc;
309    }
310}
311