0
0
Fork 0
mirror of https://github.com/nextcloud/server.git synced 2025-02-23 08:28:36 +00:00
nextcloud_server/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php
2024-10-02 15:12:26 +02:00

264 lines
8.1 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\WebcalCaching;
use OCA\DAV\CalDAV\CalDavBackend;
use OCP\AppFramework\Utility\ITimeFactory;
use Psr\Log\LoggerInterface;
use Sabre\DAV\Exception\BadRequest;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\PropPatch;
use Sabre\VObject\Component;
use Sabre\VObject\DateTimeParser;
use Sabre\VObject\InvalidDataException;
use Sabre\VObject\ParseException;
use Sabre\VObject\Reader;
use Sabre\VObject\Recur\NoInstancesException;
use Sabre\VObject\Splitter\ICalendar;
use Sabre\VObject\UUIDUtil;
use function count;
class RefreshWebcalService {
public const REFRESH_RATE = '{http://apple.com/ns/ical/}refreshrate';
public const STRIP_ALARMS = '{http://calendarserver.org/ns/}subscribed-strip-alarms';
public const STRIP_ATTACHMENTS = '{http://calendarserver.org/ns/}subscribed-strip-attachments';
public const STRIP_TODOS = '{http://calendarserver.org/ns/}subscribed-strip-todos';
public function __construct(
private CalDavBackend $calDavBackend,
private LoggerInterface $logger,
private Connection $connection,
private ITimeFactory $time,
) {
}
public function refreshSubscription(string $principalUri, string $uri) {
$subscription = $this->getSubscription($principalUri, $uri);
$mutations = [];
if (!$subscription) {
return;
}
// Check the refresh rate if there is any
if (!empty($subscription['{http://apple.com/ns/ical/}refreshrate'])) {
// add the refresh interval to the lastmodified timestamp
$refreshInterval = new \DateInterval($subscription['{http://apple.com/ns/ical/}refreshrate']);
$updateTime = $this->time->getDateTime();
$updateTime->setTimestamp($subscription['lastmodified'])->add($refreshInterval);
if ($updateTime->getTimestamp() > $this->time->getTime()) {
return;
}
}
$webcalData = $this->connection->queryWebcalFeed($subscription);
if (!$webcalData) {
return;
}
$localData = $this->calDavBackend->getLimitedCalendarObjects((int)$subscription['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
$stripTodos = ($subscription[self::STRIP_TODOS] ?? 1) === 1;
$stripAlarms = ($subscription[self::STRIP_ALARMS] ?? 1) === 1;
$stripAttachments = ($subscription[self::STRIP_ATTACHMENTS] ?? 1) === 1;
try {
$splitter = new ICalendar($webcalData, Reader::OPTION_FORGIVING);
while ($vObject = $splitter->getNext()) {
/** @var Component $vObject */
$compName = null;
$uid = null;
foreach ($vObject->getComponents() as $component) {
if ($component->name === 'VTIMEZONE') {
continue;
}
$compName = $component->name;
if ($stripAlarms) {
unset($component->{'VALARM'});
}
if ($stripAttachments) {
unset($component->{'ATTACH'});
}
$uid = $component->{ 'UID' }->getValue();
}
if ($stripTodos && $compName === 'VTODO') {
continue;
}
if (!isset($uid)) {
continue;
}
try {
$denormalized = $this->calDavBackend->getDenormalizedData($vObject->serialize());
} catch (InvalidDataException|Forbidden $ex) {
$this->logger->warning('Unable to denormalize calendar object from subscription {subscriptionId}', ['exception' => $ex, 'subscriptionId' => $subscription['id'], 'source' => $subscription['source']]);
continue;
}
// Find all identical sets and remove them from the update
if (isset($localData[$uid]) && $denormalized['etag'] === $localData[$uid]['etag']) {
unset($localData[$uid]);
continue;
}
$vObjectCopy = clone $vObject;
$identical = isset($localData[$uid]) && $this->compareWithoutDtstamp($vObjectCopy, $localData[$uid]);
if ($identical) {
unset($localData[$uid]);
continue;
}
// Find all modified sets and update them
if (isset($localData[$uid]) && $denormalized['etag'] !== $localData[$uid]['etag']) {
$this->calDavBackend->updateCalendarObject($subscription['id'], $localData[$uid]['uri'], $vObject->serialize(), CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
unset($localData[$uid]);
continue;
}
// Only entirely new events get created here
try {
$objectUri = $this->getRandomCalendarObjectUri();
$this->calDavBackend->createCalendarObject($subscription['id'], $objectUri, $vObject->serialize(), CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
} catch (NoInstancesException|BadRequest $ex) {
$this->logger->warning('Unable to create calendar object from subscription {subscriptionId}', ['exception' => $ex, 'subscriptionId' => $subscription['id'], 'source' => $subscription['source']]);
}
}
$ids = array_map(static function ($dataSet): int {
return (int)$dataSet['id'];
}, $localData);
$uris = array_map(static function ($dataSet): string {
return $dataSet['uri'];
}, $localData);
if (!empty($ids) && !empty($uris)) {
// Clean up on aisle 5
// The only events left over in the $localData array should be those that don't exist upstream
// All deleted VObjects from upstream are removed
$this->calDavBackend->purgeCachedEventsForSubscription($subscription['id'], $ids, $uris);
}
$newRefreshRate = $this->checkWebcalDataForRefreshRate($subscription, $webcalData);
if ($newRefreshRate) {
$mutations[self::REFRESH_RATE] = $newRefreshRate;
}
$this->updateSubscription($subscription, $mutations);
} catch (ParseException $ex) {
$this->logger->error('Subscription {subscriptionId} could not be refreshed due to a parsing error', ['exception' => $ex, 'subscriptionId' => $subscription['id']]);
}
}
/**
* loads subscription from backend
*/
public function getSubscription(string $principalUri, string $uri): ?array {
$subscriptions = array_values(array_filter(
$this->calDavBackend->getSubscriptionsForUser($principalUri),
function ($sub) use ($uri) {
return $sub['uri'] === $uri;
}
));
if (count($subscriptions) === 0) {
return null;
}
return $subscriptions[0];
}
/**
* check if:
* - current subscription stores a refreshrate
* - the webcal feed suggests a refreshrate
* - return suggested refreshrate if user didn't set a custom one
*
*/
private function checkWebcalDataForRefreshRate(array $subscription, string $webcalData): ?string {
// if there is no refreshrate stored in the database, check the webcal feed
// whether it suggests any refresh rate and store that in the database
if (isset($subscription[self::REFRESH_RATE]) && $subscription[self::REFRESH_RATE] !== null) {
return null;
}
/** @var Component\VCalendar $vCalendar */
$vCalendar = Reader::read($webcalData);
$newRefreshRate = null;
if (isset($vCalendar->{'X-PUBLISHED-TTL'})) {
$newRefreshRate = $vCalendar->{'X-PUBLISHED-TTL'}->getValue();
}
if (isset($vCalendar->{'REFRESH-INTERVAL'})) {
$newRefreshRate = $vCalendar->{'REFRESH-INTERVAL'}->getValue();
}
if (!$newRefreshRate) {
return null;
}
// check if new refresh rate is even valid
try {
DateTimeParser::parseDuration($newRefreshRate);
} catch (InvalidDataException $ex) {
return null;
}
return $newRefreshRate;
}
/**
* update subscription stored in database
* used to set:
* - refreshrate
* - source
*
* @param array $subscription
* @param array $mutations
*/
private function updateSubscription(array $subscription, array $mutations) {
if (empty($mutations)) {
return;
}
$propPatch = new PropPatch($mutations);
$this->calDavBackend->updateSubscription($subscription['id'], $propPatch);
$propPatch->commit();
}
/**
* Returns a random uri for a calendar-object
*
* @return string
*/
public function getRandomCalendarObjectUri():string {
return UUIDUtil::getUUID() . '.ics';
}
private function compareWithoutDtstamp(Component $vObject, array $calendarObject): bool {
foreach ($vObject->getComponents() as $component) {
unset($component->{'DTSTAMP'});
}
$localVobject = Reader::read($calendarObject['calendardata']);
foreach ($localVobject->getComponents() as $component) {
unset($component->{'DTSTAMP'});
}
return strcasecmp($localVobject->serialize(), $vObject->serialize()) === 0;
}
}