friendica-addons/dav/SabreDAV/lib/Sabre/DAV/Client.php

523 lines
16 KiB
PHP

<?php
/**
* SabreDAV DAV client
*
* This client wraps around Curl to provide a convenient API to a WebDAV
* server.
*
* NOTE: This class is experimental, it's api will likely change in the future.
*
* @package Sabre
* @subpackage DAVClient
* @copyright Copyright (C) 2007-2012 Rooftop Solutions. All rights reserved.
* @author Evert Pot (http://www.rooftopsolutions.nl/)
* @license http://code.google.com/p/sabredav/wiki/License Modified BSD License
*/
class Sabre_DAV_Client {
/**
* The propertyMap is a key-value array.
*
* If you use the propertyMap, any {DAV:}multistatus responses with the
* proeprties listed in this array, will automatically be mapped to a
* respective class.
*
* The {DAV:}resourcetype property is automatically added. This maps to
* Sabre_DAV_Property_ResourceType
*
* @var array
*/
public $propertyMap = array();
protected $baseUri;
protected $userName;
protected $password;
protected $proxy;
/**
* Basic authentication
*/
const AUTH_BASIC = 1;
/**
* Digest authentication
*/
const AUTH_DIGEST = 2;
/**
* The authentication type we're using.
*
* This is a bitmask of AUTH_BASIC and AUTH_DIGEST.
*
* If DIGEST is used, the client makes 1 extra request per request, to get
* the authentication tokens.
*
* @var int
*/
protected $authType;
/**
* Constructor
*
* Settings are provided through the 'settings' argument. The following
* settings are supported:
*
* * baseUri
* * userName (optional)
* * password (optional)
* * proxy (optional)
*
* @param array $settings
*/
public function __construct(array $settings) {
if (!isset($settings['baseUri'])) {
throw new InvalidArgumentException('A baseUri must be provided');
}
$validSettings = array(
'baseUri',
'userName',
'password',
'proxy',
);
foreach($validSettings as $validSetting) {
if (isset($settings[$validSetting])) {
$this->$validSetting = $settings[$validSetting];
}
}
if (isset($settings['authType'])) {
$this->authType = $settings['authType'];
} else {
$this->authType = self::AUTH_BASIC | self::AUTH_DIGEST;
}
$this->propertyMap['{DAV:}resourcetype'] = 'Sabre_DAV_Property_ResourceType';
}
/**
* Does a PROPFIND request
*
* The list of requested properties must be specified as an array, in clark
* notation.
*
* The returned array will contain a list of filenames as keys, and
* properties as values.
*
* The properties array will contain the list of properties. Only properties
* that are actually returned from the server (without error) will be
* returned, anything else is discarded.
*
* Depth should be either 0 or 1. A depth of 1 will cause a request to be
* made to the server to also return all child resources.
*
* @param string $url
* @param array $properties
* @param int $depth
* @return array
*/
public function propFind($url, array $properties, $depth = 0) {
$body = '<?xml version="1.0"?>' . "\n";
$body.= '<d:propfind xmlns:d="DAV:">' . "\n";
$body.= ' <d:prop>' . "\n";
foreach($properties as $property) {
list(
$namespace,
$elementName
) = Sabre_DAV_XMLUtil::parseClarkNotation($property);
if ($namespace === 'DAV:') {
$body.=' <d:' . $elementName . ' />' . "\n";
} else {
$body.=" <x:" . $elementName . " xmlns:x=\"" . $namespace . "\"/>\n";
}
}
$body.= ' </d:prop>' . "\n";
$body.= '</d:propfind>';
$response = $this->request('PROPFIND', $url, $body, array(
'Depth' => $depth,
'Content-Type' => 'application/xml'
));
$result = $this->parseMultiStatus($response['body']);
// If depth was 0, we only return the top item
if ($depth===0) {
reset($result);
$result = current($result);
return $result[200];
}
$newResult = array();
foreach($result as $href => $statusList) {
$newResult[$href] = $statusList[200];
}
return $newResult;
}
/**
* Updates a list of properties on the server
*
* The list of properties must have clark-notation properties for the keys,
* and the actual (string) value for the value. If the value is null, an
* attempt is made to delete the property.
*
* @todo Must be building the request using the DOM, and does not yet
* support complex properties.
* @param string $url
* @param array $properties
* @return void
*/
public function propPatch($url, array $properties) {
$body = '<?xml version="1.0"?>' . "\n";
$body.= '<d:propertyupdate xmlns:d="DAV:">' . "\n";
foreach($properties as $propName => $propValue) {
list(
$namespace,
$elementName
) = Sabre_DAV_XMLUtil::parseClarkNotation($propName);
if ($propValue === null) {
$body.="<d:remove><d:prop>\n";
if ($namespace === 'DAV:') {
$body.=' <d:' . $elementName . ' />' . "\n";
} else {
$body.=" <x:" . $elementName . " xmlns:x=\"" . $namespace . "\"/>\n";
}
$body.="</d:prop></d:remove>\n";
} else {
$body.="<d:set><d:prop>\n";
if ($namespace === 'DAV:') {
$body.=' <d:' . $elementName . '>';
} else {
$body.=" <x:" . $elementName . " xmlns:x=\"" . $namespace . "\">";
}
// Shitty.. i know
$body.=htmlspecialchars($propValue, ENT_NOQUOTES, 'UTF-8');
if ($namespace === 'DAV:') {
$body.='</d:' . $elementName . '>' . "\n";
} else {
$body.="</x:" . $elementName . ">\n";
}
$body.="</d:prop></d:set>\n";
}
}
$body.= '</d:propertyupdate>';
$this->request('PROPPATCH', $url, $body, array(
'Content-Type' => 'application/xml'
));
}
/**
* Performs an HTTP options request
*
* This method returns all the features from the 'DAV:' header as an array.
* If there was no DAV header, or no contents this method will return an
* empty array.
*
* @return array
*/
public function options() {
$result = $this->request('OPTIONS');
if (!isset($result['headers']['dav'])) {
return array();
}
$features = explode(',', $result['headers']['dav']);
foreach($features as &$v) {
$v = trim($v);
}
return $features;
}
/**
* Performs an actual HTTP request, and returns the result.
*
* If the specified url is relative, it will be expanded based on the base
* url.
*
* The returned array contains 3 keys:
* * body - the response body
* * httpCode - a HTTP code (200, 404, etc)
* * headers - a list of response http headers. The header names have
* been lowercased.
*
* @param string $method
* @param string $url
* @param string $body
* @param array $headers
* @return array
*/
public function request($method, $url = '', $body = null, $headers = array()) {
$url = $this->getAbsoluteUrl($url);
$curlSettings = array(
CURLOPT_RETURNTRANSFER => true,
// Return headers as part of the response
CURLOPT_HEADER => true,
CURLOPT_POSTFIELDS => $body,
// Automatically follow redirects
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
);
switch ($method) {
case 'HEAD' :
// do not read body with HEAD requests (this is neccessary because cURL does not ignore the body with HEAD
// requests when the Content-Length header is given - which in turn is perfectly valid according to HTTP
// specs...) cURL does unfortunately return an error in this case ("transfer closed transfer closed with
// ... bytes remaining to read") this can be circumvented by explicitly telling cURL to ignore the
// response body
$curlSettings[CURLOPT_NOBODY] = true;
$curlSettings[CURLOPT_CUSTOMREQUEST] = 'HEAD';
break;
default:
$curlSettings[CURLOPT_CUSTOMREQUEST] = $method;
break;
}
// Adding HTTP headers
$nHeaders = array();
foreach($headers as $key=>$value) {
$nHeaders[] = $key . ': ' . $value;
}
$curlSettings[CURLOPT_HTTPHEADER] = $nHeaders;
if ($this->proxy) {
$curlSettings[CURLOPT_PROXY] = $this->proxy;
}
if ($this->userName && $this->authType) {
$curlType = 0;
if ($this->authType & self::AUTH_BASIC) {
$curlType |= CURLAUTH_BASIC;
}
if ($this->authType & self::AUTH_DIGEST) {
$curlType |= CURLAUTH_DIGEST;
}
$curlSettings[CURLOPT_HTTPAUTH] = $curlType;
$curlSettings[CURLOPT_USERPWD] = $this->userName . ':' . $this->password;
}
list(
$response,
$curlInfo,
$curlErrNo,
$curlError
) = $this->curlRequest($url, $curlSettings);
$headerBlob = substr($response, 0, $curlInfo['header_size']);
$response = substr($response, $curlInfo['header_size']);
// In the case of 100 Continue, or redirects we'll have multiple lists
// of headers for each separate HTTP response. We can easily split this
// because they are separated by \r\n\r\n
$headerBlob = explode("\r\n\r\n", trim($headerBlob, "\r\n"));
// We only care about the last set of headers
$headerBlob = $headerBlob[count($headerBlob)-1];
// Splitting headers
$headerBlob = explode("\r\n", $headerBlob);
$headers = array();
foreach($headerBlob as $header) {
$parts = explode(':', $header, 2);
if (count($parts)==2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
}
$response = array(
'body' => $response,
'statusCode' => $curlInfo['http_code'],
'headers' => $headers
);
if ($curlErrNo) {
throw new Sabre_DAV_Exception('[CURL] Error while making request: ' . $curlError . ' (error code: ' . $curlErrNo . ')');
}
if ($response['statusCode']>=400) {
switch ($response['statusCode']) {
case 400 :
throw new Sabre_DAV_Exception_BadRequest('Bad request');
case 401 :
throw new Sabre_DAV_Exception_NotAuthenticated('Not authenticated');
case 402 :
throw new Sabre_DAV_Exception_PaymentRequired('Payment required');
case 403 :
throw new Sabre_DAV_Exception_Forbidden('Forbidden');
case 404:
throw new Sabre_DAV_Exception_NotFound('Resource not found.');
case 405 :
throw new Sabre_DAV_Exception_MethodNotAllowed('Method not allowed');
case 409 :
throw new Sabre_DAV_Exception_Conflict('Conflict');
case 412 :
throw new Sabre_DAV_Exception_PreconditionFailed('Precondition failed');
case 416 :
throw new Sabre_DAV_Exception_RequestedRangeNotSatisfiable('Requested Range Not Satisfiable');
case 500 :
throw new Sabre_DAV_Exception('Internal server error');
case 501 :
throw new Sabre_DAV_Exception_NotImplemented('Not Implemeneted');
case 507 :
throw new Sabre_DAV_Exception_InsufficientStorage('Insufficient storage');
default:
throw new Sabre_DAV_Exception('HTTP error response. (errorcode ' . $response['statusCode'] . ')');
}
}
return $response;
}
/**
* Wrapper for all curl functions.
*
* The only reason this was split out in a separate method, is so it
* becomes easier to unittest.
*
* @param string $url
* @param array $settings
* @return array
*/
protected function curlRequest($url, $settings) {
$curl = curl_init($url);
curl_setopt_array($curl, $settings);
return array(
curl_exec($curl),
curl_getinfo($curl),
curl_errno($curl),
curl_error($curl)
);
}
/**
* Returns the full url based on the given url (which may be relative). All
* urls are expanded based on the base url as given by the server.
*
* @param string $url
* @return string
*/
protected function getAbsoluteUrl($url) {
// If the url starts with http:// or https://, the url is already absolute.
if (preg_match('/^http(s?):\/\//', $url)) {
return $url;
}
// If the url starts with a slash, we must calculate the url based off
// the root of the base url.
if (strpos($url,'/') === 0) {
$parts = parse_url($this->baseUri);
return $parts['scheme'] . '://' . $parts['host'] . (isset($parts['port'])?':' . $parts['port']:'') . $url;
}
// Otherwise...
return $this->baseUri . $url;
}
/**
* Parses a WebDAV multistatus response body
*
* This method returns an array with the following structure
*
* array(
* 'url/to/resource' => array(
* '200' => array(
* '{DAV:}property1' => 'value1',
* '{DAV:}property2' => 'value2',
* ),
* '404' => array(
* '{DAV:}property1' => null,
* '{DAV:}property2' => null,
* ),
* )
* 'url/to/resource2' => array(
* .. etc ..
* )
* )
*
*
* @param string $body xml body
* @return array
*/
public function parseMultiStatus($body) {
$responseXML = simplexml_load_string($body, null, LIBXML_NOBLANKS | LIBXML_NOCDATA);
if ($responseXML===false) {
throw new InvalidArgumentException('The passed data is not valid XML');
}
$responseXML->registerXPathNamespace('d', 'DAV:');
$propResult = array();
foreach($responseXML->xpath('d:response') as $response) {
$response->registerXPathNamespace('d', 'DAV:');
$href = $response->xpath('d:href');
$href = (string)$href[0];
$properties = array();
foreach($response->xpath('d:propstat') as $propStat) {
$propStat->registerXPathNamespace('d', 'DAV:');
$status = $propStat->xpath('d:status');
list($httpVersion, $statusCode, $message) = explode(' ', (string)$status[0],3);
$properties[$statusCode] = Sabre_DAV_XMLUtil::parseProperties(dom_import_simplexml($propStat), $this->propertyMap);
}
$propResult[$href] = $properties;
}
return $propResult;
}
}