Files
luxtools-plugin/src/CalDavClient.php
2026-03-18 14:17:38 +01:00

538 lines
17 KiB
PHP

<?php
namespace dokuwiki\plugin\luxtools;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Reader;
use Throwable;
/**
* CalDAV client for remote calendar operations.
*
* Supports:
* - Downloading a full calendar collection into a local ICS file (sync)
* - Updating the STATUS of a single event/task occurrence on the remote server
*
* Uses plain PHP curl for HTTP. No additional dependencies required.
*/
class CalDavClient
{
/** @var int HTTP timeout in seconds */
protected const TIMEOUT = 30;
/** @var string Last request error message for diagnostics */
protected static string $lastRequestError = '';
/**
* Update the STATUS of a specific event or task on the remote CalDAV server.
*
* Fetches the calendar object containing the UID, modifies its status,
* and PUTs it back using the ETag for conflict detection.
*
* @param string $caldavUrl CalDAV collection URL
* @param string $username HTTP Basic auth username
* @param string $password HTTP Basic auth password
* @param string $uid Event/task UID
* @param string $recurrenceId Recurrence ID (empty for non-recurring)
* @param string $newStatus New status value (e.g. COMPLETED, TODO)
* @param string $dateIso Occurrence date YYYY-MM-DD
* @return string Empty string on success, error message on failure
*/
public static function updateEventStatus(
string $caldavUrl,
string $username,
string $password,
string $uid,
string $recurrenceId,
string $newStatus,
string $dateIso
): string {
if ($caldavUrl === '' || $uid === '') return 'Missing CalDAV URL or UID';
try {
// Find the calendar object href for this UID via REPORT
$objectInfo = self::findObjectByUid($caldavUrl, $username, $password, $uid);
if ($objectInfo === null) {
$msg = "CalDAV: Could not find object with UID '$uid' on server";
dbglog($msg);
return $msg;
}
$objectHref = $objectInfo['href'];
$etag = $objectInfo['etag'];
$calendarData = $objectInfo['data'];
// Parse and update the status
$calendar = Reader::read($calendarData, Reader::OPTION_FORGIVING);
if (!($calendar instanceof VCalendar)) {
$msg = "CalDAV: Failed to parse calendar data for UID '$uid'";
dbglog($msg);
return $msg;
}
$updated = IcsWriter::applyStatusUpdateToCalendar(
$calendar, $uid, $recurrenceId, $newStatus, $dateIso
);
if (!$updated) {
$msg = "CalDAV: applyStatusUpdateToCalendar failed for UID '$uid'";
dbglog($msg);
return $msg;
}
$newData = $calendar->serialize();
// PUT the updated object back with If-Match for conflict detection
$putError = self::putCalendarObject($objectHref, $username, $password, $newData, $etag);
if ($putError !== '') {
dbglog($putError);
return $putError;
}
return '';
} catch (Throwable $e) {
$msg = 'CalDAV: Exception during updateEventStatus: ' . $e->getMessage();
dbglog($msg);
return $msg;
}
}
/**
* Sync a remote CalDAV calendar collection into the slot's local ICS file.
*
* Downloads all calendar objects from the collection and merges them
* into a single ICS file at the slot's configured file path.
*
* @param CalendarSlot $slot
* @return bool True if sync succeeded
*/
public static function syncSlot(CalendarSlot $slot): bool
{
if (!$slot->hasRemoteSource()) return false;
$caldavUrl = $slot->getCaldavUrl();
$username = $slot->getUsername();
$password = $slot->getPassword();
$localFile = $slot->getFile();
if ($localFile === '') {
// No local file configured - nothing to sync into
return false;
}
try {
$objects = self::fetchAllCalendarObjects($caldavUrl, $username, $password);
if ($objects === null) return false;
$merged = self::mergeCalendarObjects($objects);
if ($merged === '') return false;
return IcsWriter::atomicWritePublic($localFile, $merged);
} catch (Throwable $e) {
return false;
}
}
/**
* Find a specific calendar object by UID using a REPORT request.
*
* @param string $caldavUrl
* @param string $username
* @param string $password
* @param string $uid
* @return array{href: string, etag: string, data: string}|null
*/
protected static function findObjectByUid(
string $caldavUrl,
string $username,
string $password,
string $uid
): ?array {
$body = '<?xml version="1.0" encoding="utf-8" ?>' .
'<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:prop-filter name="UID">' .
'<C:text-match collation="i;octet">' . htmlspecialchars($uid, ENT_XML1, 'UTF-8') . '</C:text-match>' .
'</C:prop-filter>' .
'</C:comp-filter>' .
'</C:comp-filter>' .
'</C:filter>' .
'</C:calendar-query>';
$response = self::request('REPORT', $caldavUrl, $username, $password, $body, [
'Content-Type: application/xml; charset=utf-8',
'Depth: 1',
]);
if ($response === null) {
return null;
}
return self::parseReportResponse($response, $caldavUrl);
}
/**
* Fetch all calendar objects from a CalDAV collection.
*
* @param string $caldavUrl
* @param string $username
* @param string $password
* @return string[]|null Array of ICS data strings, or null on failure
*/
protected static function fetchAllCalendarObjects(
string $caldavUrl,
string $username,
string $password
): ?array {
$body = '<?xml version="1.0" encoding="utf-8" ?>' .
'<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' .
'<D:prop>' .
'<C:calendar-data/>' .
'</D:prop>' .
'<C:filter>' .
'<C:comp-filter name="VCALENDAR"/>' .
'</C:filter>' .
'</C:calendar-query>';
$response = self::request('REPORT', $caldavUrl, $username, $password, $body, [
'Content-Type: application/xml; charset=utf-8',
'Depth: 1',
]);
if ($response === null) return null;
return self::parseCalendarDataFromMultistatus($response);
}
/**
* Merge multiple ICS calendar objects into a single calendar string.
*
* @param string[] $objects Array of ICS data strings
* @return string Merged ICS content
*/
protected static function mergeCalendarObjects(array $objects): string
{
if ($objects === []) return '';
$merged = new VCalendar();
$merged->PRODID = '-//LuxTools DokuWiki Plugin//CalDAV Sync//EN';
$merged->VERSION = '2.0';
foreach ($objects as $icsData) {
if (trim($icsData) === '') continue;
try {
$cal = Reader::read($icsData, Reader::OPTION_FORGIVING);
if (!($cal instanceof VCalendar)) continue;
// Copy VTIMEZONE components first
foreach ($cal->select('VTIMEZONE') as $tz) {
// Check if this timezone already exists in merged
$tzid = (string)($tz->TZID ?? '');
$exists = false;
foreach ($merged->select('VTIMEZONE') as $existingTz) {
if ((string)($existingTz->TZID ?? '') === $tzid) {
$exists = true;
break;
}
}
if (!$exists) {
$merged->add(clone $tz);
}
}
// Copy VEVENT components
foreach ($cal->select('VEVENT') as $component) {
$merged->add(clone $component);
}
} catch (Throwable $e) {
// Skip malformed objects
continue;
}
}
return $merged->serialize();
}
/**
* PUT a calendar object back to the server.
*
* @param string $href Full URL of the calendar object
* @param string $username
* @param string $password
* @param string $data ICS data to write
* @param string $etag ETag for If-Match header (empty to skip)
* @return string Empty string on success, error message on failure
*/
protected static function putCalendarObject(
string $href,
string $username,
string $password,
string $data,
string $etag
): string {
$headers = [
'Content-Type: text/calendar; charset=utf-8',
];
if ($etag !== '') {
$headers[] = 'If-Match: ' . $etag;
}
$response = self::request('PUT', $href, $username, $password, $data, $headers);
if ($response === null) {
return self::$lastRequestError ?: 'CalDAV PUT failed (unknown error)';
}
return '';
}
/**
* Parse a REPORT multistatus response to extract href, etag, and calendar data
* for the first matching object.
*
* @param string $xml
* @param string $baseUrl
* @return array{href: string, etag: string, data: string}|null
*/
protected static function parseReportResponse(string $xml, string $baseUrl): ?array
{
$doc = self::parseXml($xml);
if ($doc === null) return null;
$doc->registerXPathNamespace('d', 'DAV:');
$doc->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
$responses = $doc->xpath('//d:response');
if (!$responses || count($responses) === 0) return null;
foreach ($responses as $resp) {
$resp->registerXPathNamespace('d', 'DAV:');
$resp->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
$hrefs = $resp->xpath('d:href');
$href = ($hrefs && count($hrefs) > 0) ? trim((string)$hrefs[0]) : '';
$etags = $resp->xpath('.//d:getetag');
$etag = ($etags && count($etags) > 0) ? trim((string)$etags[0]) : '';
$caldata = $resp->xpath('.//cal:calendar-data');
$data = ($caldata && count($caldata) > 0) ? trim((string)$caldata[0]) : '';
if ($href === '' || $data === '') continue;
// Resolve relative href to absolute URL
if (strpos($href, 'http') !== 0) {
$parsed = parse_url($baseUrl);
$scheme = ($parsed['scheme'] ?? 'https');
$host = ($parsed['host'] ?? '');
$port = isset($parsed['port']) ? (':' . $parsed['port']) : '';
$href = $scheme . '://' . $host . $port . $href;
}
return [
'href' => $href,
'etag' => $etag,
'data' => $data,
];
}
return null;
}
/**
* Parse calendar-data elements from a CalDAV multistatus response.
*
* @param string $xml
* @return string[]
*/
protected static function parseCalendarDataFromMultistatus(string $xml): array
{
$doc = self::parseXml($xml);
if ($doc === null) return [];
$doc->registerXPathNamespace('d', 'DAV:');
$doc->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
$results = [];
$responses = $doc->xpath('//d:response');
if (!$responses) return [];
foreach ($responses as $resp) {
$resp->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
$caldata = $resp->xpath('.//cal:calendar-data');
if ($caldata && count($caldata) > 0) {
$data = trim((string)$caldata[0]);
if ($data !== '') {
$results[] = $data;
}
}
}
return $results;
}
/**
* Parse an XML string safely.
*
* @param string $xml
* @return \SimpleXMLElement|null
*/
protected static function parseXml(string $xml): ?\SimpleXMLElement
{
if (trim($xml) === '') return null;
// Disable external entity loading for security
$prev = libxml_use_internal_errors(true);
try {
$doc = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT | LIBXML_NONET);
libxml_clear_errors();
return ($doc !== false) ? $doc : null;
} finally {
libxml_use_internal_errors($prev);
}
}
/**
* Public wrapper for findObjectByUid.
*
* @param string $caldavUrl
* @param string $username
* @param string $password
* @param string $uid
* @return array{href: string, etag: string, data: string}|null
*/
public static function findObjectByUidPublic(
string $caldavUrl,
string $username,
string $password,
string $uid
): ?array {
return self::findObjectByUid($caldavUrl, $username, $password, $uid);
}
/**
* Public wrapper for putCalendarObject.
*
* @param string $href
* @param string $username
* @param string $password
* @param string $data
* @param string $etag
* @return string Empty string on success, error on failure
*/
public static function putCalendarObjectPublic(
string $href,
string $username,
string $password,
string $data,
string $etag
): string {
return self::putCalendarObject($href, $username, $password, $data, $etag);
}
/**
* Delete a calendar object from the server.
*
* @param string $href Full URL of the calendar object
* @param string $username
* @param string $password
* @param string $etag ETag for If-Match header (empty to skip)
* @return bool True on success
*/
public static function deleteCalendarObject(
string $href,
string $username,
string $password,
string $etag
): bool {
$headers = [];
if ($etag !== '') {
$headers[] = 'If-Match: ' . $etag;
}
$response = self::request('DELETE', $href, $username, $password, '', $headers);
return $response !== null;
}
/**
* Perform an HTTP request to a CalDAV server.
*
* @param string $method HTTP method (GET, PUT, REPORT, PROPFIND, etc.)
* @param string $url Full URL
* @param string $username
* @param string $password
* @param string $body Request body (empty for GET)
* @param string[] $headers Additional HTTP headers
* @return string|null Response body, or null on failure
*/
protected static function request(
string $method,
string $url,
string $username,
string $password,
string $body = '',
array $headers = []
): ?string {
self::$lastRequestError = '';
if (!function_exists('curl_init')) {
self::$lastRequestError = 'CalDAV: curl extension not available';
return null;
}
$ch = curl_init();
if ($ch === false) {
self::$lastRequestError = 'CalDAV: curl_init() failed';
return null;
}
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
// Authentication
if ($username !== '') {
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt($ch, CURLOPT_USERPWD, $username . ':' . $password);
}
// Request body
if ($body !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
// HTTP headers
if ($headers !== []) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
// Capture HTTP status code
$responseBody = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if (!is_string($responseBody)) {
self::$lastRequestError = "CalDAV $method failed: curl error: $curlError";
return null;
}
// Accept 2xx and 207 (multistatus) responses
if ($httpCode >= 200 && $httpCode < 300) {
return $responseBody;
}
if ($httpCode === 207) {
return $responseBody;
}
self::$lastRequestError = "CalDAV $method failed: HTTP $httpCode";
return null;
}
}