1<?php
2
3namespace SabreForRainLoop\DAV;
4
5/**
6 * SabreDAV DAV client
7 *
8 * This client wraps around Curl to provide a convenient API to a WebDAV
9 * server.
10 *
11 * NOTE: This class is experimental, it's api will likely change in the future.
12 *
13 * @copyright Copyright (C) 2007-2013 fruux GmbH (https://fruux.com/).
14 * @author Evert Pot (http://evertpot.com/)
15 * @license http://code.google.com/p/sabredav/wiki/License Modified BSD License
16 */
17class Client {
18
19    /**
20     * The propertyMap is a key-value array.
21     *
22     * If you use the propertyMap, any {DAV:}multistatus responses with the
23     * proeprties listed in this array, will automatically be mapped to a
24     * respective class.
25     *
26     * The {DAV:}resourcetype property is automatically added. This maps to
27     * SabreForRainLoop\DAV\Property\ResourceType
28     *
29     * @var array
30     */
31    public $propertyMap = array();
32
33    protected $baseUri;
34    protected $userName;
35    protected $password;
36    protected $proxy;
37    protected $trustedCertificates;
38
39    /**
40     * Basic authentication
41     */
42    const AUTH_BASIC = 1;
43
44    /**
45     * Digest authentication
46     */
47    const AUTH_DIGEST = 2;
48
49    /**
50     * The authentication type we're using.
51     *
52     * This is a bitmask of AUTH_BASIC and AUTH_DIGEST.
53     *
54     * If DIGEST is used, the client makes 1 extra request per request, to get
55     * the authentication tokens.
56     *
57     * @var int
58     */
59    protected $authType;
60
61    /**
62     * Indicates if SSL verification is enabled or not.
63     *
64     * @var boolean
65     */
66    protected $verifyPeer;
67
68    /**
69     * Constructor
70     *
71     * Settings are provided through the 'settings' argument. The following
72     * settings are supported:
73     *
74     *   * baseUri
75     *   * userName (optional)
76     *   * password (optional)
77     *   * proxy (optional)
78     *
79     * @param array $settings
80     */
81    public function __construct(array $settings) {
82
83        if (!isset($settings['baseUri'])) {
84            throw new \InvalidArgumentException('A baseUri must be provided');
85        }
86
87        $validSettings = array(
88            'baseUri',
89            'userName',
90            'password',
91            'proxy',
92        );
93
94        foreach($validSettings as $validSetting) {
95            if (isset($settings[$validSetting])) {
96                $this->$validSetting = $settings[$validSetting];
97            }
98        }
99
100        if (isset($settings['authType'])) {
101            $this->authType = $settings['authType'];
102        } else {
103            $this->authType = self::AUTH_BASIC | self::AUTH_DIGEST;
104        }
105
106        $this->propertyMap['{DAV:}resourcetype'] = 'SabreForRainLoop\\DAV\\Property\\ResourceType';
107
108    }
109
110    /**
111     * Add trusted root certificates to the webdav client.
112     *
113     * The parameter certificates should be a absolute path to a file
114     * which contains all trusted certificates
115     *
116     * @param string $certificates
117     */
118    public function addTrustedCertificates($certificates) {
119        $this->trustedCertificates = $certificates;
120    }
121
122    /**
123     * Enables/disables SSL peer verification
124     *
125     * @param boolean $value
126     */
127    public function setVerifyPeer($value) {
128        $this->verifyPeer = $value;
129    }
130
131    /**
132     * Does a PROPFIND request
133     *
134     * The list of requested properties must be specified as an array, in clark
135     * notation.
136     *
137     * The returned array will contain a list of filenames as keys, and
138     * properties as values.
139     *
140     * The properties array will contain the list of properties. Only properties
141     * that are actually returned from the server (without error) will be
142     * returned, anything else is discarded.
143     *
144     * Depth should be either 0 or 1. A depth of 1 will cause a request to be
145     * made to the server to also return all child resources.
146     *
147     * @param string $url
148     * @param array $properties
149     * @param int $depth
150     * @return array
151     */
152    public function propFind($url, array $properties, $depth = 0) {
153
154        $body = '<?xml version="1.0"?>' . "\n";
155        $body.= '<d:propfind xmlns:d="DAV:">' . "\n";
156        $body.= '  <d:prop>' . "\n";
157
158        foreach($properties as $property) {
159
160            list(
161                $namespace,
162                $elementName
163            ) = XMLUtil::parseClarkNotation($property);
164
165            if ($namespace === 'DAV:') {
166                $body.='    <d:' . $elementName . ' />' . "\n";
167            } else {
168                $body.="    <x:" . $elementName . " xmlns:x=\"" . $namespace . "\"/>\n";
169            }
170
171        }
172
173        $body.= '  </d:prop>' . "\n";
174        $body.= '</d:propfind>';
175
176        $response = $this->request('PROPFIND', $url, $body, array(
177            'Depth' => $depth,
178            'Content-Type' => 'application/xml'
179        ));
180
181        $result = $this->parseMultiStatus($response['body']);
182
183        // If depth was 0, we only return the top item
184        if ($depth===0) {
185            reset($result);
186            $result = current($result);
187            return isset($result[200])?$result[200]:array();
188        }
189
190        $newResult = array();
191        foreach($result as $href => $statusList) {
192
193            $newResult[$href] = isset($statusList[200])?$statusList[200]:array();
194
195        }
196
197        return $newResult;
198
199    }
200
201    /**
202     * Updates a list of properties on the server
203     *
204     * The list of properties must have clark-notation properties for the keys,
205     * and the actual (string) value for the value. If the value is null, an
206     * attempt is made to delete the property.
207     *
208     * @todo Must be building the request using the DOM, and does not yet
209     *       support complex properties.
210     * @param string $url
211     * @param array $properties
212     * @return void
213     */
214    public function propPatch($url, array $properties) {
215
216        $body = '<?xml version="1.0"?>' . "\n";
217        $body.= '<d:propertyupdate xmlns:d="DAV:">' . "\n";
218
219        foreach($properties as $propName => $propValue) {
220
221            list(
222                $namespace,
223                $elementName
224            ) = XMLUtil::parseClarkNotation($propName);
225
226            if ($propValue === null) {
227
228                $body.="<d:remove><d:prop>\n";
229
230                if ($namespace === 'DAV:') {
231                    $body.='    <d:' . $elementName . ' />' . "\n";
232                } else {
233                    $body.="    <x:" . $elementName . " xmlns:x=\"" . $namespace . "\"/>\n";
234                }
235
236                $body.="</d:prop></d:remove>\n";
237
238            } else {
239
240                $body.="<d:set><d:prop>\n";
241                if ($namespace === 'DAV:') {
242                    $body.='    <d:' . $elementName . '>';
243                } else {
244                    $body.="    <x:" . $elementName . " xmlns:x=\"" . $namespace . "\">";
245                }
246                // Shitty.. i know
247                $body.=htmlspecialchars($propValue, ENT_NOQUOTES, 'UTF-8');
248                if ($namespace === 'DAV:') {
249                    $body.='</d:' . $elementName . '>' . "\n";
250                } else {
251                    $body.="</x:" . $elementName . ">\n";
252                }
253                $body.="</d:prop></d:set>\n";
254
255            }
256
257        }
258
259        $body.= '</d:propertyupdate>';
260
261        $this->request('PROPPATCH', $url, $body, array(
262            'Content-Type' => 'application/xml'
263        ));
264
265    }
266
267    /**
268     * Performs an HTTP options request
269     *
270     * This method returns all the features from the 'DAV:' header as an array.
271     * If there was no DAV header, or no contents this method will return an
272     * empty array.
273     *
274     * @return array
275     */
276    public function options() {
277
278        $result = $this->request('OPTIONS');
279        if (!isset($result['headers']['dav'])) {
280            return array();
281        }
282
283        $features = explode(',', $result['headers']['dav']);
284        foreach($features as &$v) {
285            $v = trim($v);
286        }
287        return $features;
288
289    }
290
291    /**
292     * Performs an actual HTTP request, and returns the result.
293     *
294     * If the specified url is relative, it will be expanded based on the base
295     * url.
296     *
297     * The returned array contains 3 keys:
298     *   * body - the response body
299     *   * httpCode - a HTTP code (200, 404, etc)
300     *   * headers - a list of response http headers. The header names have
301     *     been lowercased.
302     *
303     * @param string $method
304     * @param string $url
305     * @param string $body
306     * @param array $headers
307     * @return array
308     */
309    public function request($method, $url = '', $body = null, $headers = array()) {
310
311        $url = $this->getAbsoluteUrl($url);
312
313        $curlSettings = array(
314            CURLOPT_RETURNTRANSFER => true,
315            // Return headers as part of the response
316            CURLOPT_HEADER => true,
317            CURLOPT_POSTFIELDS => $body,
318			CURLOPT_USERAGENT => 'RainLoop DAV Client', // TODO rainloop
319            // Automatically follow redirects
320            CURLOPT_FOLLOWLOCATION => true,
321            CURLOPT_MAXREDIRS => 5,
322        );
323
324        if($this->verifyPeer !== null) {
325            $curlSettings[CURLOPT_SSL_VERIFYPEER] = $this->verifyPeer;
326	        // TODO rainloop
327	        if (!$this->verifyPeer) {
328		        $curlSettings[CURLOPT_SSL_VERIFYHOST] = 0;
329	        } // ---
330        }
331
332	    if($this->trustedCertificates) {
333            $curlSettings[CURLOPT_CAINFO] = $this->trustedCertificates;
334        }
335
336        switch ($method) {
337            case 'HEAD' :
338
339                // do not read body with HEAD requests (this is necessary because cURL does not ignore the body with HEAD
340                // requests when the Content-Length header is given - which in turn is perfectly valid according to HTTP
341                // specs...) cURL does unfortunately return an error in this case ("transfer closed transfer closed with
342                // ... bytes remaining to read") this can be circumvented by explicitly telling cURL to ignore the
343                // response body
344                $curlSettings[CURLOPT_NOBODY] = true;
345                $curlSettings[CURLOPT_CUSTOMREQUEST] = 'HEAD';
346                break;
347
348            default:
349                $curlSettings[CURLOPT_CUSTOMREQUEST] = $method;
350                break;
351
352        }
353
354        // Adding HTTP headers
355        $nHeaders = array();
356        foreach($headers as $key=>$value) {
357
358            $nHeaders[] = $key . ': ' . $value;
359
360        }
361        $curlSettings[CURLOPT_HTTPHEADER] = $nHeaders;
362
363        if ($this->proxy) {
364            $curlSettings[CURLOPT_PROXY] = $this->proxy;
365        }
366
367        if ($this->userName && $this->authType) {
368            $curlType = 0;
369            if ($this->authType & self::AUTH_BASIC) {
370                $curlType |= CURLAUTH_BASIC;
371            }
372            if ($this->authType & self::AUTH_DIGEST) {
373                $curlType |= CURLAUTH_DIGEST;
374            }
375            $curlSettings[CURLOPT_HTTPAUTH] = $curlType;
376            $curlSettings[CURLOPT_USERPWD] = $this->userName . ':' . $this->password;
377        }
378
379//		var_dump($url);
380//		var_dump($curlSettings);
381
382        list(
383            $response,
384            $curlInfo,
385            $curlErrNo,
386            $curlError
387        ) = $this->curlRequest($url, $curlSettings);
388
389//		var_dump($response);
390
391        $headerBlob = substr($response, 0, $curlInfo['header_size']);
392        $response = substr($response, $curlInfo['header_size']);
393
394
395        // In the case of 100 Continue, or redirects we'll have multiple lists
396        // of headers for each separate HTTP response. We can easily split this
397        // because they are separated by \r\n\r\n
398        $headerBlob = explode("\r\n\r\n", trim($headerBlob, "\r\n"));
399
400        // We only care about the last set of headers
401        $headerBlob = $headerBlob[count($headerBlob)-1];
402
403        // Splitting headers
404        $headerBlob = explode("\r\n", $headerBlob);
405
406        $headers = array();
407        foreach($headerBlob as $header) {
408            $parts = explode(':', $header, 2);
409            if (count($parts)==2) {
410                $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
411            }
412        }
413
414        $response = array(
415            'body' => $response,
416            'statusCode' => $curlInfo['http_code'],
417            'headers' => $headers
418        );
419
420        if ($curlErrNo) {
421            throw new Exception('[CURL] Error while making request: ' . $curlError . ' (error code: ' . $curlErrNo . ')');
422        }
423
424        if ($response['statusCode']>=400) {
425            switch ($response['statusCode']) {
426                case 400 :
427                    throw new Exception\BadRequest('Bad request');
428                case 401 :
429                    throw new Exception\NotAuthenticated('Not authenticated');
430                case 402 :
431                    throw new Exception\PaymentRequired('Payment required');
432                case 403 :
433                    throw new Exception\Forbidden('Forbidden');
434                case 404:
435                    throw new Exception\NotFound('Resource not found.');
436                case 405 :
437                    throw new Exception\MethodNotAllowed('Method not allowed');
438                case 409 :
439                    throw new Exception\Conflict('Conflict');
440                case 412 :
441                    throw new Exception\PreconditionFailed('Precondition failed');
442                case 416 :
443                    throw new Exception\RequestedRangeNotSatisfiable('Requested Range Not Satisfiable');
444                case 500 :
445                    throw new Exception('Internal server error');
446                case 501 :
447                    throw new Exception\NotImplemented('Not Implemented');
448                case 507 :
449                    throw new Exception\InsufficientStorage('Insufficient storage');
450                default:
451                    throw new Exception('HTTP error response. (errorcode ' . $response['statusCode'] . ')');
452            }
453        }
454
455        return $response;
456
457    }
458
459    /**
460     * Wrapper for all curl functions.
461     *
462     * The only reason this was split out in a separate method, is so it
463     * becomes easier to unittest.
464     *
465     * @param string $url
466     * @param array $settings
467     * @return array
468     */
469    // @codeCoverageIgnoreStart
470    protected function curlRequest($url, $settings) {
471
472	    // TODO rainloop
473        $curl = curl_init($url);
474		$sSafeMode = strtolower(trim(@ini_get('safe_mode')));
475		$bSafeMode = 'on' === $sSafeMode || '1' === $sSafeMode;
476
477		if (!$bSafeMode && ini_get('open_basedir') === '')
478		{
479			curl_setopt_array($curl, $settings);
480			$data = curl_exec($curl);
481		}
482		else
483		{
484			$settings[CURLOPT_FOLLOWLOCATION] = false;
485			curl_setopt_array($curl, $settings);
486
487			$max_redirects = isset($settings[CURLOPT_MAXREDIRS]) ? $settings[CURLOPT_MAXREDIRS] : 5;
488			$mr = $max_redirects;
489			if ($mr > 0)
490			{
491				$newurl = curl_getinfo($curl, CURLINFO_EFFECTIVE_URL);
492
493				$rcurl = curl_copy_handle($curl);
494				curl_setopt($rcurl, CURLOPT_HEADER, true);
495				curl_setopt($rcurl, CURLOPT_NOBODY, true);
496				curl_setopt($rcurl, CURLOPT_FORBID_REUSE, false);
497				curl_setopt($rcurl, CURLOPT_RETURNTRANSFER, true);
498				do
499				{
500					curl_setopt($rcurl, CURLOPT_URL, $newurl);
501					$header = curl_exec($rcurl);
502					if (curl_errno($rcurl))
503					{
504						$code = 0;
505					}
506					else
507					{
508						$code = curl_getinfo($rcurl, CURLINFO_HTTP_CODE);
509						if ($code == 301 || $code == 302)
510						{
511							$matches = array();
512							preg_match('/Location:(.*?)\n/', $header, $matches);
513							$newurl = trim(array_pop($matches));
514						}
515						else
516						{
517							$code = 0;
518						}
519					}
520				} while ($code && --$mr);
521
522				curl_close($rcurl);
523				if ($mr > 0)
524				{
525					curl_setopt($curl, CURLOPT_URL, $newurl);
526				}
527			}
528
529			if ($mr == 0 && $max_redirects > 0)
530			{
531				$data = false;
532			}
533			else
534			{
535				$data = curl_exec($curl);
536			}
537		}
538
539        return array(
540            $data,
541            curl_getinfo($curl),
542            curl_errno($curl),
543            curl_error($curl)
544        );
545
546    }
547    // @codeCoverageIgnoreEnd
548
549    /**
550     * Returns the full url based on the given url (which may be relative). All
551     * urls are expanded based on the base url as given by the server.
552     *
553     * @param string $url
554     * @return string
555     */
556    protected function getAbsoluteUrl($url) {
557
558        // If the url starts with http:// or https://, the url is already absolute.
559        if (preg_match('/^http(s?):\/\//', $url)) {
560            return $url;
561        }
562
563        // If the url starts with a slash, we must calculate the url based off
564        // the root of the base url.
565        if (strpos($url,'/') === 0) {
566            $parts = parse_url($this->baseUri);
567            return $parts['scheme'] . '://' . $parts['host'] . (isset($parts['port'])?':' . $parts['port']:'') . $url;
568        }
569
570        // Otherwise...
571        return $this->baseUri . $url;
572
573    }
574
575    /**
576     * Parses a WebDAV multistatus response body
577     *
578     * This method returns an array with the following structure
579     *
580     * array(
581     *   'url/to/resource' => array(
582     *     '200' => array(
583     *        '{DAV:}property1' => 'value1',
584     *        '{DAV:}property2' => 'value2',
585     *     ),
586     *     '404' => array(
587     *        '{DAV:}property1' => null,
588     *        '{DAV:}property2' => null,
589     *     ),
590     *   )
591     *   'url/to/resource2' => array(
592     *      .. etc ..
593     *   )
594     * )
595     *
596     *
597     * @param string $body xml body
598     * @return array
599     */
600    public function parseMultiStatus($body) {
601
602        $body = XMLUtil::convertDAVNamespace($body);
603
604        $responseXML = simplexml_load_string($body, null, LIBXML_NOBLANKS | LIBXML_NOCDATA);
605        if ($responseXML===false) {
606            throw new \InvalidArgumentException('The passed data is not valid XML');
607        }
608
609        $responseXML->registerXPathNamespace('d', 'urn:DAV');
610
611        $propResult = array();
612
613        foreach($responseXML->xpath('d:response') as $response) {
614            $response->registerXPathNamespace('d', 'urn:DAV');
615            $href = $response->xpath('d:href');
616            $href = (string)$href[0];
617
618            $properties = array();
619
620            foreach($response->xpath('d:propstat') as $propStat) {
621
622                $propStat->registerXPathNamespace('d', 'urn:DAV');
623                $status = $propStat->xpath('d:status');
624                list($httpVersion, $statusCode, $message) = explode(' ', (string)$status[0],3);
625
626                // Only using the propertymap for results with status 200.
627                $propertyMap = $statusCode==='200' ? $this->propertyMap : array();
628
629                $properties[$statusCode] = XMLUtil::parseProperties(dom_import_simplexml($propStat), $propertyMap);
630
631            }
632
633            $propResult[$href] = $properties;
634
635        }
636
637        return $propResult;
638
639    }
640
641}
642