189 lines
6.8 KiB
PHP
189 lines
6.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Mummert\CalDavSyncBundle\CalDav\Transport;
|
|
|
|
use RuntimeException;
|
|
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
|
|
final readonly class CalDavTransport implements CalDavTransportInterface
|
|
{
|
|
public function __construct(private HttpClientInterface $httpClient)
|
|
{
|
|
}
|
|
|
|
public function propfind(string $url, string $username, string $password, string $body, int $depth = 1, array $headers = []): TransportResponse
|
|
{
|
|
$headers = array_merge(['Depth' => (string) $depth], $headers);
|
|
|
|
return $this->request('PROPFIND', $url, $username, $password, $body, $headers);
|
|
}
|
|
|
|
public function report(string $url, string $username, string $password, string $body, int $depth = 1, array $headers = []): TransportResponse
|
|
{
|
|
$headers = array_merge(['Depth' => (string) $depth], $headers);
|
|
|
|
return $this->request('REPORT', $url, $username, $password, $body, $headers);
|
|
}
|
|
|
|
public function get(string $url, string $username, string $password, array $headers = []): TransportResponse
|
|
{
|
|
return $this->request('GET', $url, $username, $password, null, $headers);
|
|
}
|
|
|
|
public function put(string $url, string $username, string $password, string $body, array $headers = []): TransportResponse
|
|
{
|
|
return $this->request('PUT', $url, $username, $password, $body, $headers);
|
|
}
|
|
|
|
public function delete(string $url, string $username, string $password, array $headers = []): TransportResponse
|
|
{
|
|
return $this->request('DELETE', $url, $username, $password, null, $headers);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $headers
|
|
*/
|
|
private function request(string $method, string $url, string $username, string $password, ?string $body, array $headers): TransportResponse
|
|
{
|
|
$requestHeaders = [
|
|
'Accept' => 'application/xml,text/xml,text/calendar,*/*',
|
|
...$headers,
|
|
];
|
|
|
|
try {
|
|
$response = $this->httpClient->request($method, $url, [
|
|
'auth_basic' => [$username, $password],
|
|
'headers' => $requestHeaders,
|
|
'body' => $body,
|
|
]);
|
|
|
|
$statusCode = $response->getStatusCode();
|
|
$rawHeaders = $response->getHeaders(false);
|
|
$normalizedHeaders = [];
|
|
|
|
foreach ($rawHeaders as $name => $values) {
|
|
$normalizedHeaders[strtolower($name)] = implode(', ', $values);
|
|
}
|
|
|
|
if (401 === $statusCode && isset($normalizedHeaders['www-authenticate']) && str_contains(strtolower($normalizedHeaders['www-authenticate']), 'digest')) {
|
|
$digestAuthorization = $this->buildDigestAuthorizationHeader(
|
|
$normalizedHeaders['www-authenticate'],
|
|
$method,
|
|
$url,
|
|
$username,
|
|
$password,
|
|
);
|
|
|
|
if (null !== $digestAuthorization) {
|
|
$retryResponse = $this->httpClient->request($method, $url, [
|
|
'headers' => [
|
|
...$requestHeaders,
|
|
'Authorization' => $digestAuthorization,
|
|
],
|
|
'body' => $body,
|
|
]);
|
|
|
|
$statusCode = $retryResponse->getStatusCode();
|
|
$rawHeaders = $retryResponse->getHeaders(false);
|
|
$normalizedHeaders = [];
|
|
|
|
foreach ($rawHeaders as $name => $values) {
|
|
$normalizedHeaders[strtolower($name)] = implode(', ', $values);
|
|
}
|
|
|
|
$content = $retryResponse->getContent(false);
|
|
|
|
return new TransportResponse($statusCode, $content, $normalizedHeaders);
|
|
}
|
|
}
|
|
|
|
$content = $response->getContent(false);
|
|
|
|
return new TransportResponse($statusCode, $content, $normalizedHeaders);
|
|
} catch (TransportExceptionInterface $e) {
|
|
throw new RuntimeException(sprintf('CalDAV transport error for %s %s: %s', $method, $url, $e->getMessage()), 0, $e);
|
|
}
|
|
}
|
|
|
|
private function buildDigestAuthorizationHeader(string $wwwAuthenticateHeader, string $method, string $url, string $username, string $password): ?string
|
|
{
|
|
$challenge = trim($wwwAuthenticateHeader);
|
|
$digestPos = stripos($challenge, 'Digest ');
|
|
if (false === $digestPos) {
|
|
return null;
|
|
}
|
|
|
|
$challengeBody = trim(substr($challenge, $digestPos + 7));
|
|
preg_match_all('/([a-zA-Z0-9_-]+)=("[^"]*"|[^,]+)/', $challengeBody, $matches, PREG_SET_ORDER);
|
|
|
|
$params = [];
|
|
foreach ($matches as $match) {
|
|
$params[strtolower($match[1])] = trim($match[2], " \t\n\r\0\x0B\"");
|
|
}
|
|
|
|
$realm = $params['realm'] ?? null;
|
|
$nonce = $params['nonce'] ?? null;
|
|
if (null === $realm || null === $nonce) {
|
|
return null;
|
|
}
|
|
|
|
$qop = null;
|
|
if (isset($params['qop'])) {
|
|
$qopCandidates = array_map('trim', explode(',', $params['qop']));
|
|
$qop = in_array('auth', $qopCandidates, true) ? 'auth' : ('' !== ($qopCandidates[0] ?? '') ? $qopCandidates[0] : null);
|
|
}
|
|
|
|
$algorithm = strtoupper((string) ($params['algorithm'] ?? 'MD5'));
|
|
$uri = (string) (parse_url($url, PHP_URL_PATH) ?? '/');
|
|
$query = parse_url($url, PHP_URL_QUERY);
|
|
if (is_string($query) && '' !== $query) {
|
|
$uri .= '?'.$query;
|
|
}
|
|
|
|
$cnonce = bin2hex(random_bytes(8));
|
|
$nc = '00000001';
|
|
|
|
$ha1 = md5($username.':'.$realm.':'.$password);
|
|
if ('MD5-SESS' === $algorithm) {
|
|
$ha1 = md5($ha1.':'.$nonce.':'.$cnonce);
|
|
}
|
|
|
|
$ha2 = md5($method.':'.$uri);
|
|
|
|
if (null !== $qop) {
|
|
$response = md5($ha1.':'.$nonce.':'.$nc.':'.$cnonce.':'.$qop.':'.$ha2);
|
|
} else {
|
|
$response = md5($ha1.':'.$nonce.':'.$ha2);
|
|
}
|
|
|
|
$headerParts = [
|
|
'Digest username="'.$this->escapeDigestValue($username).'"',
|
|
'realm="'.$this->escapeDigestValue($realm).'"',
|
|
'nonce="'.$this->escapeDigestValue($nonce).'"',
|
|
'uri="'.$this->escapeDigestValue($uri).'"',
|
|
'response="'.$response.'"',
|
|
'algorithm='.$algorithm,
|
|
];
|
|
|
|
if (isset($params['opaque'])) {
|
|
$headerParts[] = 'opaque="'.$this->escapeDigestValue((string) $params['opaque']).'"';
|
|
}
|
|
|
|
if (null !== $qop) {
|
|
$headerParts[] = 'qop='.$qop;
|
|
$headerParts[] = 'nc='.$nc;
|
|
$headerParts[] = 'cnonce="'.$cnonce.'"';
|
|
}
|
|
|
|
return implode(', ', $headerParts);
|
|
}
|
|
|
|
private function escapeDigestValue(string $value): string
|
|
{
|
|
return addcslashes($value, "\\\"");
|
|
}
|
|
}
|