6 * This client wraps around Curl to provide a convenient API to a WebDAV
9 * NOTE: This class is experimental, it's api will likely change in the future.
12 * @subpackage DAVClient
13 * @copyright Copyright (C) 2007-2012 Rooftop Solutions. All rights reserved.
14 * @author Evert Pot (http://www.rooftopsolutions.nl/)
15 * @license http://code.google.com/p/sabredav/wiki/License Modified BSD License
17 class Sabre_DAV_Client {
20 * The propertyMap is a key-value array.
22 * If you use the propertyMap, any {DAV:}multistatus responses with the
23 * proeprties listed in this array, will automatically be mapped to a
26 * The {DAV:}resourcetype property is automatically added. This maps to
27 * Sabre_DAV_Property_ResourceType
31 public $propertyMap = array();
39 * Basic authentication
44 * Digest authentication
46 const AUTH_DIGEST = 2;
49 * The authentication type we're using.
51 * This is a bitmask of AUTH_BASIC and AUTH_DIGEST.
53 * If DIGEST is used, the client makes 1 extra request per request, to get
54 * the authentication tokens.
63 * Settings are provided through the 'settings' argument. The following
64 * settings are supported:
67 * * userName (optional)
68 * * password (optional)
71 * @param array $settings
73 public function __construct(array $settings) {
75 if (!isset($settings['baseUri'])) {
76 throw new InvalidArgumentException('A baseUri must be provided');
79 $validSettings = array(
86 foreach($validSettings as $validSetting) {
87 if (isset($settings[$validSetting])) {
88 $this->$validSetting = $settings[$validSetting];
92 if (isset($settings['authType'])) {
93 $this->authType = $settings['authType'];
95 $this->authType = self::AUTH_BASIC | self::AUTH_DIGEST;
98 $this->propertyMap['{DAV:}resourcetype'] = 'Sabre_DAV_Property_ResourceType';
103 * Does a PROPFIND request
105 * The list of requested properties must be specified as an array, in clark
108 * The returned array will contain a list of filenames as keys, and
109 * properties as values.
111 * The properties array will contain the list of properties. Only properties
112 * that are actually returned from the server (without error) will be
113 * returned, anything else is discarded.
115 * Depth should be either 0 or 1. A depth of 1 will cause a request to be
116 * made to the server to also return all child resources.
119 * @param array $properties
123 public function propFind($url, array $properties, $depth = 0) {
125 $body = '<?xml version="1.0"?>' . "\n";
126 $body.= '<d:propfind xmlns:d="DAV:">' . "\n";
127 $body.= ' <d:prop>' . "\n";
129 foreach($properties as $property) {
134 ) = Sabre_DAV_XMLUtil::parseClarkNotation($property);
136 if ($namespace === 'DAV:') {
137 $body.=' <d:' . $elementName . ' />' . "\n";
139 $body.=" <x:" . $elementName . " xmlns:x=\"" . $namespace . "\"/>\n";
144 $body.= ' </d:prop>' . "\n";
145 $body.= '</d:propfind>';
147 $response = $this->request('PROPFIND', $url, $body, array(
149 'Content-Type' => 'application/xml'
152 $result = $this->parseMultiStatus($response['body']);
154 // If depth was 0, we only return the top item
157 $result = current($result);
161 $newResult = array();
162 foreach($result as $href => $statusList) {
164 $newResult[$href] = $statusList[200];
173 * Updates a list of properties on the server
175 * The list of properties must have clark-notation properties for the keys,
176 * and the actual (string) value for the value. If the value is null, an
177 * attempt is made to delete the property.
179 * @todo Must be building the request using the DOM, and does not yet
180 * support complex properties.
182 * @param array $properties
185 public function propPatch($url, array $properties) {
187 $body = '<?xml version="1.0"?>' . "\n";
188 $body.= '<d:propertyupdate xmlns:d="DAV:">' . "\n";
190 foreach($properties as $propName => $propValue) {
195 ) = Sabre_DAV_XMLUtil::parseClarkNotation($propName);
197 if ($propValue === null) {
199 $body.="<d:remove><d:prop>\n";
201 if ($namespace === 'DAV:') {
202 $body.=' <d:' . $elementName . ' />' . "\n";
204 $body.=" <x:" . $elementName . " xmlns:x=\"" . $namespace . "\"/>\n";
207 $body.="</d:prop></d:remove>\n";
211 $body.="<d:set><d:prop>\n";
212 if ($namespace === 'DAV:') {
213 $body.=' <d:' . $elementName . '>';
215 $body.=" <x:" . $elementName . " xmlns:x=\"" . $namespace . "\">";
218 $body.=htmlspecialchars($propValue, ENT_NOQUOTES, 'UTF-8');
219 if ($namespace === 'DAV:') {
220 $body.='</d:' . $elementName . '>' . "\n";
222 $body.="</x:" . $elementName . ">\n";
224 $body.="</d:prop></d:set>\n";
230 $body.= '</d:propertyupdate>';
232 $this->request('PROPPATCH', $url, $body, array(
233 'Content-Type' => 'application/xml'
239 * Performs an HTTP options request
241 * This method returns all the features from the 'DAV:' header as an array.
242 * If there was no DAV header, or no contents this method will return an
247 public function options() {
249 $result = $this->request('OPTIONS');
250 if (!isset($result['headers']['dav'])) {
254 $features = explode(',', $result['headers']['dav']);
255 foreach($features as &$v) {
263 * Performs an actual HTTP request, and returns the result.
265 * If the specified url is relative, it will be expanded based on the base
268 * The returned array contains 3 keys:
269 * * body - the response body
270 * * httpCode - a HTTP code (200, 404, etc)
271 * * headers - a list of response http headers. The header names have
274 * @param string $method
276 * @param string $body
277 * @param array $headers
280 public function request($method, $url = '', $body = null, $headers = array()) {
282 $url = $this->getAbsoluteUrl($url);
284 $curlSettings = array(
285 CURLOPT_RETURNTRANSFER => true,
286 // Return headers as part of the response
287 CURLOPT_HEADER => true,
288 CURLOPT_POSTFIELDS => $body,
289 // Automatically follow redirects
290 CURLOPT_FOLLOWLOCATION => true,
291 CURLOPT_MAXREDIRS => 5,
297 // do not read body with HEAD requests (this is neccessary because cURL does not ignore the body with HEAD
298 // requests when the Content-Length header is given - which in turn is perfectly valid according to HTTP
299 // specs...) cURL does unfortunately return an error in this case ("transfer closed transfer closed with
300 // ... bytes remaining to read") this can be circumvented by explicitly telling cURL to ignore the
302 $curlSettings[CURLOPT_NOBODY] = true;
303 $curlSettings[CURLOPT_CUSTOMREQUEST] = 'HEAD';
307 $curlSettings[CURLOPT_CUSTOMREQUEST] = $method;
312 // Adding HTTP headers
314 foreach($headers as $key=>$value) {
316 $nHeaders[] = $key . ': ' . $value;
319 $curlSettings[CURLOPT_HTTPHEADER] = $nHeaders;
322 $curlSettings[CURLOPT_PROXY] = $this->proxy;
325 if ($this->userName && $this->authType) {
327 if ($this->authType & self::AUTH_BASIC) {
328 $curlType |= CURLAUTH_BASIC;
330 if ($this->authType & self::AUTH_DIGEST) {
331 $curlType |= CURLAUTH_DIGEST;
333 $curlSettings[CURLOPT_HTTPAUTH] = $curlType;
334 $curlSettings[CURLOPT_USERPWD] = $this->userName . ':' . $this->password;
342 ) = $this->curlRequest($url, $curlSettings);
344 $headerBlob = substr($response, 0, $curlInfo['header_size']);
345 $response = substr($response, $curlInfo['header_size']);
347 // In the case of 100 Continue, or redirects we'll have multiple lists
348 // of headers for each separate HTTP response. We can easily split this
349 // because they are separated by \r\n\r\n
350 $headerBlob = explode("\r\n\r\n", trim($headerBlob, "\r\n"));
352 // We only care about the last set of headers
353 $headerBlob = $headerBlob[count($headerBlob)-1];
356 $headerBlob = explode("\r\n", $headerBlob);
359 foreach($headerBlob as $header) {
360 $parts = explode(':', $header, 2);
361 if (count($parts)==2) {
362 $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
368 'statusCode' => $curlInfo['http_code'],
369 'headers' => $headers
373 throw new Sabre_DAV_Exception('[CURL] Error while making request: ' . $curlError . ' (error code: ' . $curlErrNo . ')');
376 if ($response['statusCode']>=400) {
377 switch ($response['statusCode']) {
379 throw new Sabre_DAV_Exception_BadRequest('Bad request');
381 throw new Sabre_DAV_Exception_NotAuthenticated('Not authenticated');
383 throw new Sabre_DAV_Exception_PaymentRequired('Payment required');
385 throw new Sabre_DAV_Exception_Forbidden('Forbidden');
387 throw new Sabre_DAV_Exception_NotFound('Resource not found.');
389 throw new Sabre_DAV_Exception_MethodNotAllowed('Method not allowed');
391 throw new Sabre_DAV_Exception_Conflict('Conflict');
393 throw new Sabre_DAV_Exception_PreconditionFailed('Precondition failed');
395 throw new Sabre_DAV_Exception_RequestedRangeNotSatisfiable('Requested Range Not Satisfiable');
397 throw new Sabre_DAV_Exception('Internal server error');
399 throw new Sabre_DAV_Exception_NotImplemented('Not Implemeneted');
401 throw new Sabre_DAV_Exception_InsufficientStorage('Insufficient storage');
403 throw new Sabre_DAV_Exception('HTTP error response. (errorcode ' . $response['statusCode'] . ')');
412 * Wrapper for all curl functions.
414 * The only reason this was split out in a separate method, is so it
415 * becomes easier to unittest.
418 * @param array $settings
421 protected function curlRequest($url, $settings) {
423 $curl = curl_init($url);
424 curl_setopt_array($curl, $settings);
436 * Returns the full url based on the given url (which may be relative). All
437 * urls are expanded based on the base url as given by the server.
442 protected function getAbsoluteUrl($url) {
444 // If the url starts with http:// or https://, the url is already absolute.
445 if (preg_match('/^http(s?):\/\//', $url)) {
449 // If the url starts with a slash, we must calculate the url based off
450 // the root of the base url.
451 if (strpos($url,'/') === 0) {
452 $parts = parse_url($this->baseUri);
453 return $parts['scheme'] . '://' . $parts['host'] . (isset($parts['port'])?':' . $parts['port']:'') . $url;
457 return $this->baseUri . $url;
462 * Parses a WebDAV multistatus response body
464 * This method returns an array with the following structure
467 * 'url/to/resource' => array(
469 * '{DAV:}property1' => 'value1',
470 * '{DAV:}property2' => 'value2',
473 * '{DAV:}property1' => null,
474 * '{DAV:}property2' => null,
477 * 'url/to/resource2' => array(
483 * @param string $body xml body
486 public function parseMultiStatus($body) {
488 $responseXML = simplexml_load_string($body, null, LIBXML_NOBLANKS | LIBXML_NOCDATA);
489 if ($responseXML===false) {
490 throw new InvalidArgumentException('The passed data is not valid XML');
493 $responseXML->registerXPathNamespace('d', 'DAV:');
495 $propResult = array();
497 foreach($responseXML->xpath('d:response') as $response) {
498 $response->registerXPathNamespace('d', 'DAV:');
499 $href = $response->xpath('d:href');
500 $href = (string)$href[0];
502 $properties = array();
504 foreach($response->xpath('d:propstat') as $propStat) {
506 $propStat->registerXPathNamespace('d', 'DAV:');
507 $status = $propStat->xpath('d:status');
508 list($httpVersion, $statusCode, $message) = explode(' ', (string)$status[0],3);
510 $properties[$statusCode] = Sabre_DAV_XMLUtil::parseProperties(dom_import_simplexml($propStat), $this->propertyMap);
514 $propResult[$href] = $properties;