1<?php
2
3namespace Sabre\DAV;
4
5use Sabre\HTTP;
6use Sabre\Uri;
7
8/**
9 * SabreDAV DAV client
10 *
11 * This client wraps around Curl to provide a convenient API to a WebDAV
12 * server.
13 *
14 * NOTE: This class is experimental, it's api will likely change in the future.
15 *
16 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
17 * @author Evert Pot (http://evertpot.com/)
18 * @license http://sabre.io/license/ Modified BSD License
19 */
20class Client extends HTTP\Client {
21
22    /**
23     * The xml service.
24     *
25     * Uset this service to configure the property and namespace maps.
26     *
27     * @var mixed
28     */
29    public $xml;
30
31    /**
32     * The elementMap
33     *
34     * This property is linked via reference to $this->xml->elementMap.
35     * It's deprecated as of version 3.0.0, and should no longer be used.
36     *
37     * @deprecated
38     * @var array
39     */
40    public $propertyMap = [];
41
42    /**
43     * Base URI
44     *
45     * This URI will be used to resolve relative urls.
46     *
47     * @var string
48     */
49    protected $baseUri;
50
51    /**
52     * Basic authentication
53     */
54    const AUTH_BASIC = 1;
55
56    /**
57     * Digest authentication
58     */
59    const AUTH_DIGEST = 2;
60
61    /**
62     * NTLM authentication
63     */
64    const AUTH_NTLM = 4;
65
66    /**
67     * Identity encoding, which basically does not nothing.
68     */
69    const ENCODING_IDENTITY = 1;
70
71    /**
72     * Deflate encoding
73     */
74    const ENCODING_DEFLATE = 2;
75
76    /**
77     * Gzip encoding
78     */
79    const ENCODING_GZIP = 4;
80
81    /**
82     * Sends all encoding headers.
83     */
84    const ENCODING_ALL = 7;
85
86    /**
87     * Content-encoding
88     *
89     * @var int
90     */
91    protected $encoding = self::ENCODING_IDENTITY;
92
93    /**
94     * Constructor
95     *
96     * Settings are provided through the 'settings' argument. The following
97     * settings are supported:
98     *
99     *   * baseUri
100     *   * userName (optional)
101     *   * password (optional)
102     *   * proxy (optional)
103     *   * authType (optional)
104     *   * encoding (optional)
105     *
106     *  authType must be a bitmap, using self::AUTH_BASIC, self::AUTH_DIGEST
107     *  and self::AUTH_NTLM. If you know which authentication method will be
108     *  used, it's recommended to set it, as it will save a great deal of
109     *  requests to 'discover' this information.
110     *
111     *  Encoding is a bitmap with one of the ENCODING constants.
112     *
113     * @param array $settings
114     */
115    function __construct(array $settings) {
116
117        if (!isset($settings['baseUri'])) {
118            throw new \InvalidArgumentException('A baseUri must be provided');
119        }
120
121        parent::__construct();
122
123        $this->baseUri = $settings['baseUri'];
124
125        if (isset($settings['proxy'])) {
126            $this->addCurlSetting(CURLOPT_PROXY, $settings['proxy']);
127        }
128
129        if (isset($settings['userName'])) {
130            $userName = $settings['userName'];
131            $password = isset($settings['password']) ? $settings['password'] : '';
132
133            if (isset($settings['authType'])) {
134                $curlType = 0;
135                if ($settings['authType'] & self::AUTH_BASIC) {
136                    $curlType |= CURLAUTH_BASIC;
137                }
138                if ($settings['authType'] & self::AUTH_DIGEST) {
139                    $curlType |= CURLAUTH_DIGEST;
140                }
141                if ($settings['authType'] & self::AUTH_NTLM) {
142                    $curlType |= CURLAUTH_NTLM;
143                }
144            } else {
145                $curlType = CURLAUTH_BASIC | CURLAUTH_DIGEST;
146            }
147
148            $this->addCurlSetting(CURLOPT_HTTPAUTH, $curlType);
149            $this->addCurlSetting(CURLOPT_USERPWD, $userName . ':' . $password);
150
151        }
152
153        if (isset($settings['encoding'])) {
154            $encoding = $settings['encoding'];
155
156            $encodings = [];
157            if ($encoding & self::ENCODING_IDENTITY) {
158                $encodings[] = 'identity';
159            }
160            if ($encoding & self::ENCODING_DEFLATE) {
161                $encodings[] = 'deflate';
162            }
163            if ($encoding & self::ENCODING_GZIP) {
164                $encodings[] = 'gzip';
165            }
166            $this->addCurlSetting(CURLOPT_ENCODING, implode(',', $encodings));
167        }
168
169        $this->addCurlSetting(CURLOPT_USERAGENT, 'sabre-dav/' . Version::VERSION . ' (http://sabre.io/)');
170
171        $this->xml = new Xml\Service();
172        // BC
173        $this->propertyMap = & $this->xml->elementMap;
174
175    }
176
177    /**
178     * Does a PROPFIND request
179     *
180     * The list of requested properties must be specified as an array, in clark
181     * notation.
182     *
183     * The returned array will contain a list of filenames as keys, and
184     * properties as values.
185     *
186     * The properties array will contain the list of properties. Only properties
187     * that are actually returned from the server (without error) will be
188     * returned, anything else is discarded.
189     *
190     * Depth should be either 0 or 1. A depth of 1 will cause a request to be
191     * made to the server to also return all child resources.
192     *
193     * @param string $url
194     * @param array $properties
195     * @param int $depth
196     * @return array
197     */
198    function propFind($url, array $properties, $depth = 0) {
199
200        $dom = new \DOMDocument('1.0', 'UTF-8');
201        $dom->formatOutput = true;
202        $root = $dom->createElementNS('DAV:', 'd:propfind');
203        $prop = $dom->createElement('d:prop');
204
205        foreach ($properties as $property) {
206
207            list(
208                $namespace,
209                $elementName
210            ) = \Sabre\Xml\Service::parseClarkNotation($property);
211
212            if ($namespace === 'DAV:') {
213                $element = $dom->createElement('d:' . $elementName);
214            } else {
215                $element = $dom->createElementNS($namespace, 'x:' . $elementName);
216            }
217
218            $prop->appendChild($element);
219        }
220
221        $dom->appendChild($root)->appendChild($prop);
222        $body = $dom->saveXML();
223
224        $url = $this->getAbsoluteUrl($url);
225
226        $request = new HTTP\Request('PROPFIND', $url, [
227            'Depth'        => $depth,
228            'Content-Type' => 'application/xml'
229        ], $body);
230
231        $response = $this->send($request);
232
233        if ((int)$response->getStatus() >= 400) {
234            throw new HTTP\ClientHttpException($response);
235        }
236
237        $result = $this->parseMultiStatus($response->getBodyAsString());
238
239        // If depth was 0, we only return the top item
240        if ($depth === 0) {
241            reset($result);
242            $result = current($result);
243            return isset($result[200]) ? $result[200] : [];
244        }
245
246        $newResult = [];
247        foreach ($result as $href => $statusList) {
248
249            $newResult[$href] = isset($statusList[200]) ? $statusList[200] : [];
250
251        }
252
253        return $newResult;
254
255    }
256
257    /**
258     * Updates a list of properties on the server
259     *
260     * The list of properties must have clark-notation properties for the keys,
261     * and the actual (string) value for the value. If the value is null, an
262     * attempt is made to delete the property.
263     *
264     * @param string $url
265     * @param array $properties
266     * @return bool
267     */
268    function propPatch($url, array $properties) {
269
270        $propPatch = new Xml\Request\PropPatch();
271        $propPatch->properties = $properties;
272        $xml = $this->xml->write(
273            '{DAV:}propertyupdate',
274            $propPatch
275        );
276
277        $url = $this->getAbsoluteUrl($url);
278        $request = new HTTP\Request('PROPPATCH', $url, [
279            'Content-Type' => 'application/xml',
280        ], $xml);
281        $response = $this->send($request);
282
283        if ($response->getStatus() >= 400) {
284            throw new HTTP\ClientHttpException($response);
285        }
286
287        if ($response->getStatus() === 207) {
288            // If it's a 207, the request could still have failed, but the
289            // information is hidden in the response body.
290            $result = $this->parseMultiStatus($response->getBodyAsString());
291
292            $errorProperties = [];
293            foreach ($result as $href => $statusList) {
294                foreach ($statusList as $status => $properties) {
295
296                    if ($status >= 400) {
297                        foreach ($properties as $propName => $propValue) {
298                            $errorProperties[] = $propName . ' (' . $status . ')';
299                        }
300                    }
301
302                }
303            }
304            if ($errorProperties) {
305
306                throw new HTTP\ClientException('PROPPATCH failed. The following properties errored: ' . implode(', ', $errorProperties));
307            }
308        }
309        return true;
310
311    }
312
313    /**
314     * Performs an HTTP options request
315     *
316     * This method returns all the features from the 'DAV:' header as an array.
317     * If there was no DAV header, or no contents this method will return an
318     * empty array.
319     *
320     * @return array
321     */
322    function options() {
323
324        $request = new HTTP\Request('OPTIONS', $this->getAbsoluteUrl(''));
325        $response = $this->send($request);
326
327        $dav = $response->getHeader('Dav');
328        if (!$dav) {
329            return [];
330        }
331
332        $features = explode(',', $dav);
333        foreach ($features as &$v) {
334            $v = trim($v);
335        }
336        return $features;
337
338    }
339
340    /**
341     * Performs an actual HTTP request, and returns the result.
342     *
343     * If the specified url is relative, it will be expanded based on the base
344     * url.
345     *
346     * The returned array contains 3 keys:
347     *   * body - the response body
348     *   * httpCode - a HTTP code (200, 404, etc)
349     *   * headers - a list of response http headers. The header names have
350     *     been lowercased.
351     *
352     * For large uploads, it's highly recommended to specify body as a stream
353     * resource. You can easily do this by simply passing the result of
354     * fopen(..., 'r').
355     *
356     * This method will throw an exception if an HTTP error was received. Any
357     * HTTP status code above 399 is considered an error.
358     *
359     * Note that it is no longer recommended to use this method, use the send()
360     * method instead.
361     *
362     * @param string $method
363     * @param string $url
364     * @param string|resource|null $body
365     * @param array $headers
366     * @throws ClientException, in case a curl error occurred.
367     * @return array
368     */
369    function request($method, $url = '', $body = null, array $headers = []) {
370
371        $url = $this->getAbsoluteUrl($url);
372
373        $response = $this->send(new HTTP\Request($method, $url, $headers, $body));
374        return [
375            'body'       => $response->getBodyAsString(),
376            'statusCode' => (int)$response->getStatus(),
377            'headers'    => array_change_key_case($response->getHeaders()),
378        ];
379
380    }
381
382    /**
383     * Returns the full url based on the given url (which may be relative). All
384     * urls are expanded based on the base url as given by the server.
385     *
386     * @param string $url
387     * @return string
388     */
389    function getAbsoluteUrl($url) {
390
391        return Uri\resolve(
392            $this->baseUri,
393            $url
394        );
395
396    }
397
398    /**
399     * Parses a WebDAV multistatus response body
400     *
401     * This method returns an array with the following structure
402     *
403     * [
404     *   'url/to/resource' => [
405     *     '200' => [
406     *        '{DAV:}property1' => 'value1',
407     *        '{DAV:}property2' => 'value2',
408     *     ],
409     *     '404' => [
410     *        '{DAV:}property1' => null,
411     *        '{DAV:}property2' => null,
412     *     ],
413     *   ],
414     *   'url/to/resource2' => [
415     *      .. etc ..
416     *   ]
417     * ]
418     *
419     *
420     * @param string $body xml body
421     * @return array
422     */
423    function parseMultiStatus($body) {
424
425        $multistatus = $this->xml->expect('{DAV:}multistatus', $body);
426
427        $result = [];
428
429        foreach ($multistatus->getResponses() as $response) {
430
431            $result[$response->getHref()] = $response->getResponseProperties();
432
433        }
434
435        return $result;
436
437    }
438
439}
440