(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 $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, "\\\""); } }