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\Protocol\ServerProtocolHandler;
13
14use FreeDSx\Ldap\Control\Control;
15use FreeDSx\Ldap\Control\ControlBag;
16use FreeDSx\Ldap\Control\PagingControl;
17use FreeDSx\Ldap\Entry\Entries;
18use FreeDSx\Ldap\Exception\OperationException;
19use FreeDSx\Ldap\Exception\ProtocolException;
20use FreeDSx\Ldap\Operation\ResultCode;
21use FreeDSx\Ldap\Protocol\LdapMessageRequest;
22use FreeDSx\Ldap\Protocol\Queue\ServerQueue;
23use FreeDSx\Ldap\Server\Paging\PagingRequest;
24use FreeDSx\Ldap\Server\Paging\PagingRequestComparator;
25use FreeDSx\Ldap\Server\Paging\PagingResponse;
26use FreeDSx\Ldap\Server\RequestContext;
27use FreeDSx\Ldap\Server\RequestHandler\PagingHandlerInterface;
28use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface;
29use FreeDSx\Ldap\Server\RequestHistory;
30use FreeDSx\Ldap\Server\Token\TokenInterface;
31use Throwable;
32
33/**
34 * Handles paging search request logic.
35 *
36 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
37 */
38class ServerPagingHandler implements ServerProtocolHandlerInterface
39{
40    use ServerSearchTrait;
41
42    /**
43     * @var PagingHandlerInterface
44     */
45    private $pagingHandler;
46
47    /**
48     * @var RequestHistory
49     */
50    private $requestHistory;
51
52    /**
53     * @var PagingRequestComparator
54     */
55    private $requestComparator;
56
57    public function __construct(
58        PagingHandlerInterface $pagingHandler,
59        RequestHistory $requestHistory,
60        ?PagingRequestComparator $requestComparator = null
61    ) {
62        $this->pagingHandler = $pagingHandler;
63        $this->requestHistory = $requestHistory;
64        $this->requestComparator = $requestComparator ?? new PagingRequestComparator();
65    }
66
67    /**
68     * @inheritDoc
69     * @throws ProtocolException
70     */
71    public function handleRequest(
72        LdapMessageRequest $message,
73        TokenInterface $token,
74        RequestHandlerInterface $dispatcher,
75        ServerQueue $queue,
76        array $options
77    ): void {
78        $context = new RequestContext(
79            $message->controls(),
80            $token
81        );
82        $pagingRequest = $this->findOrMakePagingRequest($message);
83
84        $response = $this->handlePaging(
85            $context,
86            $pagingRequest,
87            $message
88        );
89
90        $pagingRequest->markProcessed();
91
92        if ($response->isComplete()) {
93            $this->requestHistory->pagingRequest()
94                ->remove($pagingRequest);
95            $this->pagingHandler->remove(
96                $pagingRequest,
97                $context
98            );
99        }
100
101        $this->sendEntriesToClient(
102            $response->getEntries(),
103            $message,
104            $queue,
105            new PagingControl(
106                $response->getRemaining(),
107                $response->isComplete() ? '' : $pagingRequest->getNextCookie()
108            )
109        );
110    }
111
112    /**
113     * @throws OperationException
114     */
115    private function handlePaging(
116        RequestContext $context,
117        PagingRequest $pagingRequest,
118        LdapMessageRequest $message
119    ): PagingResponse {
120        if (!$pagingRequest->isPagingStart()) {
121            return $this->handleExistingCookie(
122                $pagingRequest,
123                $context,
124                $message
125            );
126        } else {
127            return $this->handlePagingStart(
128                $pagingRequest,
129                $context
130            );
131        }
132    }
133
134    /**
135     * @throws OperationException
136     */
137    private function handleExistingCookie(
138        PagingRequest $pagingRequest,
139        RequestContext $context,
140        LdapMessageRequest $message
141    ): PagingResponse {
142        $newPagingRequest = $this->makePagingRequest($message);
143
144        if (!$this->requestComparator->compare($pagingRequest, $newPagingRequest)) {
145            throw new OperationException(
146                'The search request and controls must be identical between paging requests.',
147                ResultCode::OPERATIONS_ERROR
148            );
149        }
150
151        $pagingRequest->updatePagingControl($this->getPagingControlFromMessage($message));
152
153        if ($pagingRequest->isAbandonRequest()) {
154            $response = PagingResponse::makeFinal(new Entries());
155        } else {
156            $response = $this->pagingHandler->page(
157                $pagingRequest,
158                $context
159            );
160            $pagingRequest->updateNextCookie($this->generateCookie());
161        }
162
163        return $response;
164    }
165
166    /**
167     * @todo It would be useful to prefix these by a unique client ID or something else somewhat identifiable.
168     * @return string
169     * @throws OperationException
170     */
171    private function generateCookie(): string
172    {
173        try {
174            return random_bytes(16);
175        } catch (Throwable $e) {
176            throw new OperationException(
177                'Internal server error.',
178                ResultCode::OPERATIONS_ERROR
179            );
180        }
181    }
182
183    /**
184     * @param LdapMessageRequest $message
185     * @return PagingRequest
186     * @throws OperationException
187     * @throws ProtocolException
188     */
189    private function findOrMakePagingRequest(LdapMessageRequest $message): PagingRequest
190    {
191        $pagingControl = $this->getPagingControlFromMessage($message);
192
193        if ($pagingControl->getCookie() !== '') {
194            return $this->findPagingRequestOrThrow($pagingControl->getCookie());
195        }
196
197        $pagingRequest = $this->makePagingRequest($message);
198        $this->requestHistory->pagingRequest()->add($pagingRequest);
199
200        return $pagingRequest;
201    }
202
203    /**
204     * @param LdapMessageRequest $message
205     * @return PagingRequest
206     * @throws OperationException
207     */
208    private function makePagingRequest(LdapMessageRequest $message): PagingRequest
209    {
210        $request = $this->getSearchRequestFromMessage($message);
211        $pagingControl = $this->getPagingControlFromMessage($message);
212
213        $filteredControls = array_filter(
214            $message->controls()->toArray(),
215            function (Control $control) {
216                return $control->getTypeOid() !== Control::OID_PAGING;
217            }
218        );
219
220        return new PagingRequest(
221            $pagingControl,
222            $request,
223            new ControlBag(...$filteredControls),
224            $this->generateCookie()
225        );
226    }
227
228    /**
229     * @param string $cookie
230     * @return PagingRequest
231     * @throws OperationException
232     */
233    private function findPagingRequestOrThrow(string $cookie): PagingRequest
234    {
235        try {
236            return $this->requestHistory
237                ->pagingRequest()
238                ->findByNextCookie($cookie);
239        } catch (ProtocolException $e) {
240            throw new OperationException(
241                $e->getMessage(),
242                ResultCode::OPERATIONS_ERROR
243            );
244        }
245    }
246
247    /**
248     * @param PagingRequest $pagingRequest
249     * @param RequestContext $context
250     * @return PagingResponse
251     * @throws OperationException
252     */
253    private function handlePagingStart(
254        PagingRequest $pagingRequest,
255        RequestContext $context
256    ): PagingResponse {
257        $response = $this->pagingHandler->page(
258            $pagingRequest,
259            $context
260        );
261        $pagingRequest->updateNextCookie($this->generateCookie());
262
263        return $response;
264    }
265}
266