<?php
/*
* CardDAV client library for PHP ("PHP-CardDavClient").
*
* Copyright (c) 2020-2021 Michael Stilkerich <ms@mike2k.de>
* Licensed under the MIT license. See COPYING file in the project root for details.
*/
declare(strict_types=1);
namespace MStilkerich\CardDavClient;
use Psr\Http\Message\ResponseInterface as Psr7Response;
use MStilkerich\CardDavClient\XmlElements\Prop;
use MStilkerich\CardDavClient\XmlElements\Filter;
use MStilkerich\CardDavClient\XmlElements\Multistatus;
use MStilkerich\CardDavClient\XmlElements\ElementNames as XmlEN;
use MStilkerich\CardDavClient\XmlElements\Deserializers;
use MStilkerich\CardDavClient\Exception\XmlParseException;
/**
* Implements the operations of the CardDAV protocol.
*
* This class implements the lower level interactions with the CardDAV server that are utilized by the higher-level
* operations offered by the public entities ({@see AddressbookCollection} etc.) and services ({@see Services\Sync},
* {@see Services\Discovery}.
*
* An application interacting with the carddavclient library should not interact with this class directly, and it is
* considered an internal part of the library whose interfaces may change without being considered a change of the
* library's API.
*
* @psalm-import-type HttpOptions from Account
* @psalm-import-type RequestOptions from HttpClientAdapter
* @psalm-import-type PropTypes from Prop
* @package Internal\Communication
*/
class CardDavClient
{
private const MAP_NS2PREFIX = [
XmlEN::NSDAV => 'DAV',
XmlEN::NSCARDDAV => 'CARDDAV',
XmlEN::NSCS => 'CS',
];
/** @var string */
protected $base_uri;
/** @var HttpClientAdapter */
protected $httpClient;
/**
* @psalm-param HttpOptions $httpOptions
*/
public function __construct(string $base_uri, array $httpOptions)
{
$this->base_uri = rtrim($base_uri, '/') . '/';
$this->httpClient = new HttpClientAdapterGuzzle($base_uri, $httpOptions);
}
/**
* Requests a sync-collection REPORT from the CardDAV server.
*
* Note: Google's server does not accept an empty syncToken, though explicitly allowed for initial sync by RFC6578.
* It will respond with 400 Bad Request and error message "Request contains an invalid argument."
*
* The Google issues have been reported to Google: https://issuetracker.google.com/issues/160190530
*/
public function syncCollection(string $addressbookUri, string $syncToken): Multistatus
{
$srv = self::getParserService();
$body = $srv->write(XmlEN::REPORT_SYNCCOLL, [
XmlEN::SYNCTOKEN => $syncToken,
XmlEN::SYNCLEVEL => "1",
XmlEN::PROP => [ XmlEN::GETETAG => null ]
]);
// RFC6578: Depth: 0 header is required for sync-collection report
// Google requires a Depth: 1 header or the REPORT will only target the collection itself
// This hack seems to be the simplest solution to behave RFC-compliant in general but have Google work
// nonetheless
if (strpos(self::concatUrl($this->base_uri, $addressbookUri), "www.googleapis.com") !== false) {
$depthValue = "1";
} else {
$depthValue = "0";
}
$response = $this->httpClient->sendRequest('REPORT', $addressbookUri, [
"headers" =>
[
"Depth" => $depthValue,
"Content-Type" => "application/xml; charset=UTF-8"
],
"body" => $body
]);
return self::checkAndParseXMLMultistatus($response);
}
public function getResource(string $uri): Psr7Response
{
$response = $this->httpClient->sendRequest('GET', $uri);
self::assertHttpStatus($response, 200, 200, "GET $uri");
$body = (string) $response->getBody();
if (empty($body)) {
throw new \Exception("Response to GET $uri request does not include a body");
}
return $response;
}
/**
* Fetches an address object.
*
* @param string $uri URI of the address object to fetch
* @psalm-return array{etag: string, vcf: string}
* @return array<string,string>
* Associative array with keys
* - etag (string): Entity tag of the created resource if returned by server, otherwise empty string.
* - vcf (string): The address data of the address object
*/
public function getAddressObject(string $uri): array
{
$response = $this->getResource($uri);
// presence of this header is required per RFC6352:
// "A response to a GET request targeted at an address object resource MUST contain an ETag response header
// field indicating the current value of the strong entity tag of the address object resource."
$etag = $response->getHeaderLine("ETag");
if (empty($etag)) {
throw new \Exception("Response to address object $uri GET request does not include ETag header");
}
$body = (string) $response->getBody(); // checked to be present in getResource()
return [ 'etag' => $etag, 'vcf' => $body ];
}
/**
* Requests the server to delete the given resource.
*/
public function deleteResource(string $uri): void
{
$response = $this->httpClient->sendRequest('DELETE', $uri);
self::assertHttpStatus($response, 200, 204, "DELETE $uri");
}
/**
* Requests the server to update the given resource.
*
* Normally, the ETag of the existing expected server-side resource should be given to make the update
* conditional on that no other changes have been done to the server-side resource, otherwise lost updates might
* occur. However, if no ETag is given, the server-side resource is overwritten unconditionally.
*
* @return ?string
* ETag of the updated resource, an empty string if no ETag was given by the server, or null if the update failed
* because the server-side ETag did not match the given one.
*/
public function updateResource(string $body, string $uri, string $etag = ""): ?string
{
$headers = [ "Content-Type" => "text/vcard" ];
if (!empty($etag)) {
$headers["If-Match"] = $etag;
}
$response = $this->httpClient->sendRequest(
'PUT',
$uri,
[
"headers" => $headers,
"body" => $body
]
);
$status = $response->getStatusCode();
if ($status == 412) {
$etag = null;
} else {
self::assertHttpStatus($response, 200, 204, "PUT $uri");
$etag = $response->getHeaderLine("ETag");
}
return $etag;
}
/**
* Requests the server to create the given resource.
*
* On success, the actual URI of the new resource is contained in the returned array.
*
* @param string $body
* The content of the newly created resource.
*
* @param string $suggestedUri
* - If $post=false: The suggested new URI for the resource to create. If a resource by that name
* already exists, names will be derived from this URI by appending a numerical suffix for a limited number of
* retries.
* - If $post=true: The "Add-Member" URI to perform the POST request to. The server chooses the URI of the new
* resource.
*
* @param bool $post
* If true, use a POST instead of a PUT request to create the resource (RFC 5995).
*
* @psalm-return array{uri: string, etag: string}
* @return array<string,string>
* Associative array with keys
* - uri (string): URI of the new resource if the request was successful
* - etag (string): Entity tag of the created resource if returned by server, otherwise empty string.
*/
public function createResource(string $body, string $suggestedUri, bool $post = false): array
{
$uri = $suggestedUri;
$attempt = 0;
$headers = [ "Content-Type" => "text/vcard" ];
if ($post) {
$reqtype = 'POST';
$retryLimit = 1;
} else {
$reqtype = 'PUT';
// for PUT, we have to guess a free URI, so we give it several tries
$retryLimit = 5;
$headers["If-None-Match"] = "*";
}
do {
++$attempt;
$response = $this->httpClient->sendRequest(
$reqtype,
$uri,
[ "headers" => $headers, "body" => $body ]
);
$status = $response->getStatusCode();
// 201 -> New resource created
// 200/204 -> Existing resource modified (should not happen b/c of If-None-Match
// 412 -> Precondition failed
if ($status == 412) {
// make up a new random filename until retry limit is hit (append a random integer to the suggested
// filename, e.g. /newcard.vcf could become /newcard-1234.vcf)
$randint = rand();
$uri = preg_replace("/(\.[^.]*)?$/", "-$randint$0", $suggestedUri, 1);
}
} while (($status == 412) && ($attempt < $retryLimit));
self::assertHttpStatus($response, 201, 201, "$reqtype $suggestedUri");
$etag = $response->getHeaderLine("ETag");
if ($post) {
$uri = $response->getHeaderLine("Location");
}
return [ 'uri' => $uri, 'etag' => $etag ];
}
/**
* Issues an addressbook-multiget request to the server.
*
* @param string $addressbookUri URI of the addressbook to fetch the objects from
* @psalm-param list<string> $requestedUris
* @param array<int,string> $requestedUris
* List of URIs of the objects to fetch
* @psalm-param list<string> $requestedVCardProps
* @param array<int,string> $requestedVCardProps
* List of VCard properties to request, empty to request the full cards.
*
* @psalm-return Multistatus<XmlElements\ResponsePropstat>
*/
public function multiGet(
string $addressbookUri,
array $requestedUris,
array $requestedVCardProps = []
): Multistatus {
$srv = self::getParserService();
// Determine the prop element for the report
$reqprops = [
XmlEN::GETETAG => null,
XmlEN::ADDRDATA => $this->determineReqCardProps($requestedVCardProps)
];
$body = $srv->write(
XmlEN::REPORT_MULTIGET,
array_merge(
[ [ 'name' => XmlEN::PROP, 'value' => $reqprops ] ],
array_map(
function (string $uri): array {
return [ 'name' => XmlEN::HREF, 'value' => $uri ];
},
$requestedUris
)
)
);
$response = $this->httpClient->sendRequest('REPORT', $addressbookUri, [
"headers" =>
[
// RFC6352: Depth: 0 header is required for addressbook-multiget report.
"Depth" => "0",
"Content-Type" => "application/xml; charset=UTF-8"
],
"body" => $body
]);
return self::checkAndParseXMLMultistatus($response, XmlElements\ResponsePropstat::class);
}
/**
* Issues an addressbook-query report.
*
* @param string $addressbookUri The URI of the addressbook collection to query
* @param Filter $filter The query filter conditions
* @psalm-param list<string> $requestedVCardProps
* @param array<int,string> $requestedVCardProps
* A list of the requested VCard properties. If empty array, the full VCards are requested from the server.
* @param int $limit Tell the server to return at most $limit results. 0 means no limit.
* @psalm-return Multistatus
*/
public function query(
string $addressbookUri,
Filter $filter,
array $requestedVCardProps,
int $limit
): Multistatus {
$srv = self::getParserService();
$reportOptions = [
// requested properties (both WebDAV and VCard properties)
[
'name' => XmlEN::PROP,
'value' => [
XmlEN::GETETAG => null,
XmlEN::ADDRDATA => $this->determineReqCardProps($requestedVCardProps)
]
],
// filter element with the conditions that cards need to match
[
'name' => XmlEN::FILTER,
'attributes' => $filter->xmlAttributes(),
'value' => $filter
]
];
// Limit element if needed
if ($limit > 0) {
$reportOptions[] = [ 'name' => XmlEN::LIMIT, 'value' => [ 'name' => XmlEN::NRESULTS, 'value' => $limit ] ];
}
$body = $srv->write(XmlEN::REPORT_QUERY, $reportOptions);
$response = $this->httpClient->sendRequest('REPORT', $addressbookUri, [
"headers" =>
[
// RFC6352: Depth: 1 header sets query scope to the addressbook collection
"Depth" => "1",
"Content-Type" => "application/xml; charset=UTF-8"
],
"body" => $body
]);
return self::checkAndParseXMLMultistatus($response);
}
/**
* Builds a CARDDAV::address-data element with the requested properties.
*
* If no properties are requested, returns null - an empty address-data element means that the full VCards shall be
* returned.
*
* Some properties that are mandatory are added to the list.
*
* @psalm-param list<string> $requestedVCardProps
* @param array<int,string> $requestedVCardProps List of the VCard properties requested by the user
* @psalm-return null|list<array{name: string, attributes: array{name: string}}>
* @return null|array<int, array<string, mixed>>
*/
private function determineReqCardProps(array $requestedVCardProps): ?array
{
if (empty($requestedVCardProps)) {
return null;
}
$requestedVCardProps = self::addRequiredVCardProperties($requestedVCardProps);
$reqprops = array_map(
function (string $prop): array {
return [
'name' => XmlEN::VCFPROP,
'attributes' => [ 'name' => $prop ]
];
},
$requestedVCardProps
);
return $reqprops;
}
/**
* Retrieves a set of WebDAV properties for a resource.
*
* @param string $uri The URI of the resource to retrieve properties for.
* @psalm-param list<string> $props
* @param array<int,string> $props
* List of properties to retrieve, given as XML element names
* @psalm-param "0"|"1"|"infinity" $depth
* @param string $depth Value for the Depth header
*
* @psalm-return list<array{uri: string, props: PropTypes}>
* @return array<int, array<string,mixed>>
*/
public function findProperties(
string $uri,
array $props,
string $depth = "0"
): array {
$srv = self::getParserService();
$body = $srv->write(XmlEN::PROPFIND, [
XmlEN::PROP => array_fill_keys($props, null)
]);
$result = $this->requestWithRedirectionTarget(
'PROPFIND',
$uri,
[
"headers" =>
[
// RFC4918: A client MUST submit a Depth header with a value of "0", "1", or "infinity"
"Depth" => $depth,
"Content-Type" => "application/xml; charset=UTF-8",
// Prefer: reduce reply size if supported, see RFC8144
"Prefer" => "return=minimal"
],
"body" => $body
]
);
$multistatus = self::checkAndParseXMLMultistatus($result["response"], XmlElements\ResponsePropstat::class);
$resultProperties = [];
foreach ($multistatus->responses as $response) {
$href = $response->href;
// There may have been redirects involved in querying the properties, particularly during addressbook
// discovery. They may even point to a different server than the original request URI. Return absolute URL
// in the responses to allow the caller to know the actual location on that the properties where reported
$respUri = self::concatUrl($result["location"], $href);
if (!empty($response->propstat)) {
foreach ($response->propstat as $propstat) {
if (stripos($propstat->status, " 200 ") !== false) {
$resultProperties[] = [ 'uri' => $respUri, 'props' => $propstat->prop->props ];
}
}
}
}
return $resultProperties;
}
/**
* Adds required VCard properties to a set specified by the user.
*
* This is needed to ensure retrieval of a valid VCard, as some properties are mandatory.
*
* @psalm-param list<string> $requestedVCardProps
* @param array<int,string> $requestedVCardProps List of properties requested by the user
* @psalm-return list<string>
* @return array<int,string> List of properties requested by the user, completed with mandatory properties.
*/
private static function addRequiredVCardProperties(array $requestedVCardProps): array
{
$minimumProps = [ 'BEGIN', 'END', 'FN', 'VERSION', 'UID' ];
foreach ($minimumProps as $prop) {
if (!in_array($prop, $requestedVCardProps)) {
$requestedVCardProps[] = $prop;
}
}
return $requestedVCardProps;
}
private static function assertHttpStatus(Psr7Response $davReply, int $minCode, int $maxCode, string $nfo): void
{
$status = $davReply->getStatusCode();
if (($status < $minCode) || ($status > $maxCode)) {
$reason = $davReply->getReasonPhrase();
$body = (string) $davReply->getBody();
throw new \Exception("$nfo HTTP request was not successful ($status $reason): $body");
}
}
/**
* @template RT of XmlElements\Response
* @psalm-param class-string<RT> $responseType
* @psalm-return Multistatus<RT>
* @return Multistatus
*/
private static function checkAndParseXMLMultistatus(
Psr7Response $davReply,
string $responseType = XmlElements\Response::class
): Multistatus {
$multistatus = null;
self::assertHttpStatus($davReply, 207, 207, "Expected Multistatus");
if (preg_match(';(?i)(text|application)/xml;', $davReply->getHeaderLine('Content-Type'))) {
$service = self::getParserService();
$multistatus = $service->expect(XmlEN::MULTISTATUS, (string) $davReply->getBody());
}
if (!($multistatus instanceof Multistatus)) {
throw new XmlParseException("Response is not the expected Multistatus response.");
}
foreach ($multistatus->responses as $response) {
if (!($response instanceof $responseType)) {
throw new XmlParseException("Multistatus contains unexpected responses (Expected: $responseType)");
}
}
/** @psalm-var Multistatus<RT> */
return $multistatus;
}
/**
* Performs a WebDAV request, automatically following redirections and providing the final target with the result.
*
* @param string $method The WebDAV method of the request (PROPFIND, REPORT, etc.)
* @param string $uri The target of the request
* @param RequestOptions $options Additional options for the request
*
* @psalm-return array{redirected: bool, location: string, response: Psr7Response}
* @return array<string, mixed>
*/
private function requestWithRedirectionTarget(string $method, string $uri, array $options = []): array
{
$options['allow_redirects'] = false;
$redirAttempt = 0;
$redirLimit = 5;
$uri = $this->absoluteUrl($uri);
do {
$response = $this->httpClient->sendRequest($method, $uri, $options);
$scode = $response->getStatusCode();
// 301 Moved Permanently
// 308 Permanent Redirect
// 302 Found
// 307 Temporary Redirect
$isRedirect = (($scode == 301) || ($scode == 302) || ($scode == 307) || ($scode == 308));
if ($isRedirect && $response->hasHeader('Location')) {
$uri = self::concatUrl($uri, $response->getHeaderLine('Location'));
$redirAttempt++;
} else {
break;
}
} while ($redirAttempt < $redirLimit);
return [
"redirected" => ($redirAttempt == 0),
"location" => $uri,
"response" => $response
];
}
public function absoluteUrl(string $relurl): string
{
return self::concatUrl($this->base_uri, $relurl);
}
public static function concatUrl(string $baseurl, string $relurl): string
{
return \Sabre\Uri\resolve($baseurl, $relurl);
}
public static function compareUrlPaths(string $url1, string $url2): bool
{
$comp1 = \Sabre\Uri\parse($url1);
$comp2 = \Sabre\Uri\parse($url2);
$p1 = trim(rtrim($comp1["path"] ?? '', "/"), "/");
$p2 = trim(rtrim($comp2["path"] ?? '', "/"), "/");
return $p1 === $p2;
}
private static function getParserService(): \Sabre\Xml\Service
{
$service = new \Sabre\Xml\Service();
$service->namespaceMap = self::MAP_NS2PREFIX;
$service->elementMap = array_merge(
Prop::PROP_DESERIALIZERS,
[
XmlEN::MULTISTATUS => XmlElements\Multistatus::class,
XmlEN::RESPONSE => XmlElements\Response::class,
XmlEN::PROPSTAT => XmlElements\Propstat::class,
XmlEN::PROP => XmlElements\Prop::class,
]
);
return $service;
}
}
// vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120