Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.28% covered (success)
95.28%
101 / 106
63.64% covered (warning)
63.64%
7 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
CurlDavClient
95.24% covered (success)
95.24%
100 / 105
63.64% covered (warning)
63.64%
7 / 11
41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listResources
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getResource
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 putResource
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 deleteResource
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 executeRequest
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
6
 parseMultiStatusResponse
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
5.07
 loadMultiStatusXml
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 parseDavResponseItem
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 extractDavProps
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 extractHeaderValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Dav\Infrastructure\Client;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Security\Http\SsrfGuard;
12use App\Modules\Dav\Domain\Exception\DavClientException;
13use App\Modules\Dav\Domain\Model\DavAccount;
14use App\Modules\Dav\Domain\Model\DavResource;
15use App\Modules\Dav\Domain\Repository\DavClientInterface;
16use SimpleXMLElement;
17
18/**
19 * High-performance HTTP cURL DAV Client.
20 *
21 * Implements CalDAV (RFC 4791) and CardDAV (RFC 6352) protocol operations with SOGo server.
22 *
23 * @package App\Modules\Dav\Infrastructure\Client
24 */
25final class CurlDavClient implements DavClientInterface
26{
27    private const int TIMEOUT_SECONDS = 30;
28
29    /**
30     * CurlDavClient constructor.
31     *
32     * @param bool $allowPrivateIps Whether to permit RFC 1918 or loopback endpoints.
33     */
34    public function __construct(
35        private bool $allowPrivateIps = false
36    ) {
37    }
38    private const string XML_CALENDAR_QUERY = <<<XML
39<?xml version="1.0" encoding="utf-8" ?>
40<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
41    <D:prop>
42        <D:getetag/>
43        <C:calendar-data/>
44    </D:prop>
45    <C:filter>
46        <C:comp-filter name="VCALENDAR">
47            <C:comp-filter name="VEVENT" />
48        </C:comp-filter>
49    </C:filter>
50</C:calendar-query>
51XML;
52
53    private const string XML_ADDRESSBOOK_QUERY = <<<XML
54<?xml version="1.0" encoding="utf-8" ?>
55<CARD:addressbook-query xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
56    <D:prop>
57        <D:getetag/>
58        <CARD:address-data/>
59    </D:prop>
60</CARD:addressbook-query>
61XML;
62
63    /**
64     * {@inheritdoc}
65     */
66    public function listResources(DavAccount $account, string $collectionUrl, string $resourceType): array
67    {
68        $isCalendar = ($resourceType === 'calendar');
69        $xmlBody = $isCalendar ? self::XML_CALENDAR_QUERY : self::XML_ADDRESSBOOK_QUERY;
70        $headers = [
71            'Depth: 1',
72            'Content-Type: application/xml; charset=utf-8',
73            'Prefer: return-minimal',
74        ];
75
76        $response = $this->executeRequest($account, 'REPORT', $collectionUrl, $headers, $xmlBody);
77        if ($response['code'] !== 207 && $response['code'] !== 200) {
78            return [];
79        }
80
81        return $this->parseMultiStatusResponse($response['body'], $collectionUrl, $isCalendar);
82    }
83
84    /**
85     * {@inheritdoc}
86     */
87    public function getResource(DavAccount $account, string $resourceUrl): DavResource
88    {
89        $headers = ['Accept: text/calendar, text/vcard, */*'];
90        $response = $this->executeRequest($account, 'GET', $resourceUrl, $headers);
91
92        if ($response['code'] !== 200) {
93            throw new DavClientException(sprintf('DAV resource fetch failed with status %d', $response['code']));
94        }
95
96        $etag = $this->extractHeaderValue($response['headers'], 'etag');
97        $contentType = $this->extractHeaderValue($response['headers'], 'content-type');
98        $uid = basename($resourceUrl, (str_contains($contentType, 'vcard') ? '.vcf' : '.ics'));
99
100        return new DavResource($uid, trim($etag, '"'), $response['body'], $contentType, $resourceUrl);
101    }
102
103    /**
104     * {@inheritdoc}
105     */
106    public function putResource(DavAccount $account, string $resourceUrl, DavResource $resource): string
107    {
108        $headers = [
109            'Content-Type: ' . $resource->contentType,
110        ];
111        if ($resource->etag !== '') {
112            $headers[] = 'If-Match: "' . trim($resource->etag, '"') . '"';
113        }
114
115        $response = $this->executeRequest($account, 'PUT', $resourceUrl, $headers, $resource->content);
116        if ($response['code'] !== 200 && $response['code'] !== 201 && $response['code'] !== 204) {
117            throw new DavClientException(
118                sprintf('DAV PUT failed with HTTP %d: %s', $response['code'], substr($response['body'], 0, 200))
119            );
120        }
121
122        $etag = $this->extractHeaderValue($response['headers'], 'etag');
123        return trim($etag, '"');
124    }
125
126    /**
127     * {@inheritdoc}
128     */
129    public function deleteResource(DavAccount $account, string $resourceUrl, string $etag = ''): bool
130    {
131        $headers = [];
132        if ($etag !== '') {
133            $headers[] = 'If-Match: "' . trim($etag, '"') . '"';
134        }
135
136        $response = $this->executeRequest($account, 'DELETE', $resourceUrl, $headers);
137        return in_array($response['code'], [200, 204, 404], true);
138    }
139
140    /**
141     * Executes an authenticated HTTP request using cURL.
142     *
143     * @param DavAccount $account User connection credentials.
144     * @param string $method HTTP method verb.
145     * @param string $url Target endpoint URL.
146     * @param array<int, string> $headers Custom HTTP request headers.
147     * @param string $body Optional request body payload.
148     * @return array{code: int, headers: string, body: string} Response structure.
149     */
150    private function executeRequest(
151        DavAccount $account,
152        string $method,
153        string $url,
154        array $headers = [],
155        string $body = ''
156    ): array {
157        if (!SsrfGuard::isUrlSafe($url, $this->allowPrivateIps)) {
158            throw new DavClientException(
159                sprintf('SSRF Protection blocked destination URL "%s". Destination is restricted.', $url)
160            );
161        }
162
163        $ch = curl_init();
164        curl_setopt($ch, CURLOPT_URL, $url);
165        curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
166        curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
167        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
168        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
169        curl_setopt($ch, CURLOPT_HEADER, true);
170        curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT_SECONDS);
171        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
172        curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
173        curl_setopt($ch, CURLOPT_USERPWD, sprintf('%s:%s', $account->email, $account->password));
174
175        if ($body !== '') {
176            curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
177        }
178        if (!empty($headers)) {
179            curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
180        }
181
182        $rawResponse = curl_exec($ch);
183        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
184        $headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
185        curl_close($ch);
186
187        if ($rawResponse === false || !is_string($rawResponse)) {
188            return ['code' => 0, 'headers' => '', 'body' => ''];
189        }
190
191        $resHeaders = substr($rawResponse, 0, $headerSize);
192        $resBody = substr($rawResponse, $headerSize);
193
194        return ['code' => $httpCode, 'headers' => $resHeaders, 'body' => $resBody];
195    }
196
197    /**
198     * Parses RFC 2518 / RFC 4791 Multi-Status XML response into DavResource collection.
199     *
200     * @param string $xmlContent Raw Multi-Status XML payload.
201     * @param string $collectionUrl Base collection URL.
202     * @param bool $isCalendar True if calendar payload, false if address book.
203     * @return array<string, DavResource> Map of UID => DavResource.
204     */
205    private function parseMultiStatusResponse(string $xmlContent, string $collectionUrl, bool $isCalendar): array
206    {
207        $xml = $this->loadMultiStatusXml($xmlContent);
208        if ($xml === null) {
209            return [];
210        }
211
212        $xml->registerXPathNamespace('d', 'DAV:');
213        $responses = $xml->xpath('//d:response');
214        if (!is_array($responses)) {
215            return [];
216        }
217
218        $collectionPath = rtrim(parse_url($collectionUrl, PHP_URL_PATH) ?? '', '/');
219        $resources = [];
220
221        foreach ($responses as $resp) {
222            $item = $this->parseDavResponseItem($resp, $collectionPath, $isCalendar);
223            if ($item !== null) {
224                $resources[$item->uid] = $item;
225            }
226        }
227
228        return $resources;
229    }
230
231    /**
232     * Parses XML string into SimpleXMLElement or returns null on failure.
233     */
234    private function loadMultiStatusXml(string $xmlContent): ?SimpleXMLElement
235    {
236        if (trim($xmlContent) === '') {
237            return null;
238        }
239
240        $xml = simplexml_load_string(
241            $xmlContent,
242            SimpleXMLElement::class,
243            LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING
244        );
245        return $xml !== false ? $xml : null;
246    }
247
248    /**
249     * Parses a single DAV response XML element into a DavResource object.
250     */
251    private function parseDavResponseItem(
252        SimpleXMLElement $resp,
253        string $collectionPath,
254        bool $isCalendar
255    ): ?DavResource {
256        $dav = $resp->children('DAV:');
257        $href = (string) ($dav->href ?? '');
258        if ($href === '' || rtrim($href, '/') === $collectionPath) {
259            return null;
260        }
261
262        $props = $this->extractDavProps($dav, $isCalendar);
263        $cleanEtag = trim($props['etag'], '"');
264        $uid = basename($href, $isCalendar ? '.ics' : '.vcf');
265        $mime = $isCalendar ? 'text/calendar; charset=utf-8' : 'text/vcard; charset=utf-8';
266
267        return new DavResource($uid, $cleanEtag, $props['data'], $mime, $href);
268    }
269
270    /**
271     * Extracts etag and payload data from DAV propstat elements.
272     *
273     * @return array{etag: string, data: string}
274     */
275    private function extractDavProps(SimpleXMLElement $dav, bool $isCalendar): array
276    {
277        $etag = '';
278        $data = '';
279
280        foreach ($dav->propstat as $propstat) {
281            $prop = $propstat->children('DAV:')->prop;
282            if ($prop === null) {
283                continue;
284            }
285
286            $etag = (string) ($prop->children('DAV:')->getetag ?? '');
287            $ns = $isCalendar ? 'urn:ietf:params:xml:ns:caldav' : 'urn:ietf:params:xml:ns:carddav';
288            $field = $isCalendar ? 'calendar-data' : 'address-data';
289            $child = $prop->children($ns);
290            $data = (string) ($child->{$field} ?? '');
291        }
292
293        return ['etag' => $etag, 'data' => $data];
294    }
295
296    /**
297     * Extracts header value case-insensitively from raw HTTP headers string.
298     *
299     * @param string $rawHeaders Raw headers block.
300     * @param string $headerName Header name to locate.
301     * @return string Found header value or empty string.
302     */
303    private function extractHeaderValue(string $rawHeaders, string $headerName): string
304    {
305        $pattern = '/^' . preg_quote($headerName, '/') . ':\s*(.+)$/im';
306        if (preg_match($pattern, $rawHeaders, $matches)) {
307            return trim($matches[1]);
308        }
309        return '';
310    }
311}