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