Files
hyprcrd/src/CalDavClient.php
T
2026-04-08 17:24:55 +02:00

258 lines
11 KiB
PHP

<?php
class CalDavClient
{
private string $baseUrl;
private string $username;
private string $password;
public function __construct(string $baseUrl, string $username, string $password)
{
$this->baseUrl = rtrim($baseUrl, '/') . '/';
$this->username = $username;
$this->password = $password;
}
public function getBaseUrl(): string { return $this->baseUrl; }
public function request(string $method, string $url, string $body = '', array $headers = []): array
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_USERPWD => $this->username . ':' . $this->password,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true,
]);
if ($body !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
// Use caller's Content-Type if provided, otherwise default to XML
$hasContentType = false;
foreach ($headers as $h) { if (stripos($h, 'Content-Type:') === 0) { $hasContentType = true; break; } }
$allHeaders = $hasContentType ? $headers : array_merge(['Content-Type: application/xml; charset=utf-8'], $headers);
curl_setopt($ch, CURLOPT_HTTPHEADER, $allHeaders);
$response = curl_exec($ch);
$error = curl_error($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
if ($response === false) {
return ['status' => 0, 'headers' => '', 'body' => '', 'error' => $error];
}
$respHeaders = substr($response, 0, $headerSize);
$respBody = substr($response, $headerSize);
// Extract ETag from headers
$etag = null;
if (preg_match('/^ETag:\s*(.+)$/mi', $respHeaders, $m)) {
$etag = trim($m[1]);
}
return ['status' => $status, 'headers' => $respHeaders, 'body' => $respBody, 'etag' => $etag, 'error' => null];
}
public function testConnection(): bool
{
$res = $this->request('PROPFIND', $this->baseUrl, '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>', ['Depth: 0']);
return $res['status'] === 207;
}
public function listCalendars(): array
{
// PROPFIND depth 1 on the base URL to find calendar collections underneath
$body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:displayname/><d:resourcetype/><cs:getctag/></d:prop></d:propfind>';
$res = $this->request('PROPFIND', $this->baseUrl, $body, ['Depth: 1']);
if ($res['status'] !== 207) return [];
$baseHref = rtrim(parse_url($this->baseUrl, PHP_URL_PATH), '/');
$calendars = [];
try {
$xml = new SimpleXMLElement($res['body']);
$xml->registerXPathNamespace('d', 'DAV:');
$xml->registerXPathNamespace('cs', 'http://calendarserver.org/ns/');
$xml->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav');
foreach ($xml->xpath('//d:response') as $resp) {
$resp->registerXPathNamespace('d', 'DAV:');
$resp->registerXPathNamespace('cs', 'http://calendarserver.org/ns/');
$resp->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav');
$href = (string) ($resp->xpath('.//d:href')[0] ?? '');
// Skip the collection itself (same path as base)
if (rtrim($href, '/') === $baseHref) continue;
// Check for calendar resource type via namespace-aware xpath
$isCal = $resp->xpath('.//d:resourcetype/c:calendar');
if (!$isCal) {
// Fallback: check raw XML for 'calendar' string
$rtXml = $resp->xpath('.//d:resourcetype');
if (!$rtXml || strpos($rtXml[0]->asXML(), 'calendar') === false) continue;
}
$name = (string) ($resp->xpath('.//d:displayname')[0] ?? basename(rtrim($href, '/')));
$ctag = (string) ($resp->xpath('.//cs:getctag')[0] ?? '');
if ($href) $calendars[] = ['name' => $name, 'href' => $href, 'ctag' => $ctag];
}
} catch (Exception $e) {}
return $calendars;
}
public function getCalendarCtag(): ?string
{
$body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"><d:prop><cs:getctag/></d:prop></d:propfind>';
$res = $this->request('PROPFIND', $this->baseUrl, $body, ['Depth: 0']);
if ($res['status'] !== 207) return null;
try {
$xml = new SimpleXMLElement($res['body']);
$xml->registerXPathNamespace('d', 'DAV:');
$xml->registerXPathNamespace('cs', 'http://calendarserver.org/ns/');
$ctag = $xml->xpath('//cs:getctag');
return $ctag ? (string) $ctag[0] : null;
} catch (Exception $e) {
return null;
}
}
public function listEvents(): array
{
$body = '<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/></c:comp-filter></c:filter></c:calendar-query>';
$res = $this->request('REPORT', $this->baseUrl, $body, ['Depth: 1']);
if ($res['status'] !== 207) return [];
$events = [];
try {
$xml = new SimpleXMLElement($res['body']);
$xml->registerXPathNamespace('d', 'DAV:');
$xml->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav');
foreach ($xml->xpath('//d:response') as $resp) {
$resp->registerXPathNamespace('d', 'DAV:');
$resp->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav');
$href = (string) ($resp->xpath('.//d:href')[0] ?? '');
$etag = (string) ($resp->xpath('.//d:getetag')[0] ?? '');
$ical = (string) ($resp->xpath('.//c:calendar-data')[0] ?? '');
if ($href && $ical) {
$events[] = ['href' => $href, 'etag' => $etag, 'ical' => $ical];
}
}
} catch (Exception $e) {}
return $events;
}
public function createEvent(string $uid, string $icalData): array
{
// Use URL-safe filename (replace @ and other special chars)
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $uid);
$url = $this->baseUrl . $filename . '.ics';
return $this->request('PUT', $url, $icalData, [
'Content-Type: text/calendar; charset=utf-8',
'If-None-Match: *',
]);
}
public function updateEvent(string $href, string $icalData, string $etag): array
{
$url = $this->resolveHref($href);
return $this->request('PUT', $url, $icalData, [
'Content-Type: text/calendar; charset=utf-8',
'If-Match: ' . $etag,
]);
}
public function deleteEvent(string $href, string $etag): array
{
$url = $this->resolveHref($href);
return $this->request('DELETE', $url, '', ['If-Match: ' . $etag]);
}
private function resolveHref(string $href): string
{
if (str_starts_with($href, 'http')) return $href;
$parsed = parse_url($this->baseUrl);
return $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '') . $href;
}
// ── iCalendar parsing ──
public static function parseVEvent(string $ical): ?array
{
// Unfold RFC 5545 line continuations
$ical = preg_replace('/\r\n[ \t]/', '', $ical);
$ical = str_replace("\r\n", "\n", $ical);
if (!preg_match('/BEGIN:VEVENT(.+?)END:VEVENT/s', $ical, $m)) return null;
$block = $m[1];
$get = function ($prop) use ($block) {
// Match property value: everything after "PROP...:" until the next property or end
if (preg_match('/^' . preg_quote($prop, '/') . '[^:]*:([\s\S]*?)(?=\n[A-Z][-A-Z]*[;:]|\z)/m', $block, $m)) return trim($m[1]);
return null;
};
return [
'uid' => $get('UID'),
'summary' => $get('SUMMARY'),
'dtstart' => $get('DTSTART'),
'dtend' => $get('DTEND'),
'location' => $get('LOCATION'),
'description' => str_replace('\\n', "\n", $get('DESCRIPTION') ?? ''),
'last_modified' => $get('LAST-MODIFIED'),
'sequence' => (int) ($get('SEQUENCE') ?? 0),
'rrule' => $get('RRULE'),
];
}
public static function buildVEvent(array $props): string
{
$fold = function ($line) {
$out = '';
while (strlen($line) > 75) {
$out .= substr($line, 0, 75) . "\r\n ";
$line = substr($line, 75);
}
return $out . $line;
};
$escape = function ($s) {
return str_replace(["\n", ",", ";", "\\"], ["\\n", "\\,", "\\;", "\\\\"], $s ?? '');
};
$lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//hyprcrd//CalDAV Client//EN',
'CALSCALE:GREGORIAN',
'BEGIN:VEVENT',
'UID:' . $props['uid'],
];
// DTSTART / DTEND
if (strlen($props['dtstart'] ?? '') === 8) {
$lines[] = 'DTSTART;VALUE=DATE:' . $props['dtstart'];
$lines[] = 'DTEND;VALUE=DATE:' . ($props['dtend'] ?? $props['dtstart']);
} else {
$lines[] = 'DTSTART:' . $props['dtstart'];
if (!empty($props['dtend'])) $lines[] = 'DTEND:' . $props['dtend'];
}
$lines[] = $fold('SUMMARY:' . $escape($props['summary'] ?? ''));
if (!empty($props['rrule'])) $lines[] = 'RRULE:' . $props['rrule'];
if (!empty($props['location'])) $lines[] = $fold('LOCATION:' . $escape($props['location']));
if (!empty($props['description'])) $lines[] = $fold('DESCRIPTION:' . $escape($props['description']));
$lines[] = 'SEQUENCE:' . ($props['sequence'] ?? 0);
$lines[] = 'LAST-MODIFIED:' . gmdate('Ymd\THis\Z');
$lines[] = 'DTSTAMP:' . gmdate('Ymd\THis\Z');
if (!empty($props['color'])) $lines[] = 'X-HYPRCRD-COLOR:' . $props['color'];
$lines[] = 'END:VEVENT';
$lines[] = 'END:VCALENDAR';
return implode("\r\n", $lines) . "\r\n";
}
}