Skip to content

Commit b8ec68d

Browse files
authored
Merge pull request #46723 from nextcloud/feat/add-delta-sync-to-subscription-calendars
feat(webcal): only update modified and deleted events from webcal calendars
2 parents 7641e76 + fb94db1 commit b8ec68d

File tree

7 files changed

+718
-426
lines changed

7 files changed

+718
-426
lines changed

apps/dav/composer/composer/autoload_classmap.php

+1
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
'OCA\\DAV\\CalDAV\\UpcomingEvent' => $baseDir . '/../lib/CalDAV/UpcomingEvent.php',
120120
'OCA\\DAV\\CalDAV\\UpcomingEventsService' => $baseDir . '/../lib/CalDAV/UpcomingEventsService.php',
121121
'OCA\\DAV\\CalDAV\\Validation\\CalDavValidatePlugin' => $baseDir . '/../lib/CalDAV/Validation/CalDavValidatePlugin.php',
122+
'OCA\\DAV\\CalDAV\\WebcalCaching\\Connection' => $baseDir . '/../lib/CalDAV/WebcalCaching/Connection.php',
122123
'OCA\\DAV\\CalDAV\\WebcalCaching\\Plugin' => $baseDir . '/../lib/CalDAV/WebcalCaching/Plugin.php',
123124
'OCA\\DAV\\CalDAV\\WebcalCaching\\RefreshWebcalService' => $baseDir . '/../lib/CalDAV/WebcalCaching/RefreshWebcalService.php',
124125
'OCA\\DAV\\Capabilities' => $baseDir . '/../lib/Capabilities.php',

apps/dav/composer/composer/autoload_static.php

+1
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ class ComposerStaticInitDAV
134134
'OCA\\DAV\\CalDAV\\UpcomingEvent' => __DIR__ . '/..' . '/../lib/CalDAV/UpcomingEvent.php',
135135
'OCA\\DAV\\CalDAV\\UpcomingEventsService' => __DIR__ . '/..' . '/../lib/CalDAV/UpcomingEventsService.php',
136136
'OCA\\DAV\\CalDAV\\Validation\\CalDavValidatePlugin' => __DIR__ . '/..' . '/../lib/CalDAV/Validation/CalDavValidatePlugin.php',
137+
'OCA\\DAV\\CalDAV\\WebcalCaching\\Connection' => __DIR__ . '/..' . '/../lib/CalDAV/WebcalCaching/Connection.php',
137138
'OCA\\DAV\\CalDAV\\WebcalCaching\\Plugin' => __DIR__ . '/..' . '/../lib/CalDAV/WebcalCaching/Plugin.php',
138139
'OCA\\DAV\\CalDAV\\WebcalCaching\\RefreshWebcalService' => __DIR__ . '/..' . '/../lib/CalDAV/WebcalCaching/RefreshWebcalService.php',
139140
'OCA\\DAV\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php',

apps/dav/lib/CalDAV/CalDavBackend.php

+76
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,43 @@ public function restoreCalendar(int $id): void {
945945
}, $this->db);
946946
}
947947

948+
/**
949+
* Returns all calendar objects with limited metadata for a calendar
950+
*
951+
* Every item contains an array with the following keys:
952+
* * id - the table row id
953+
* * etag - An arbitrary string
954+
* * uri - a unique key which will be used to construct the uri. This can
955+
* be any arbitrary string.
956+
* * calendardata - The iCalendar-compatible calendar data
957+
*
958+
* @param mixed $calendarId
959+
* @param int $calendarType
960+
* @return array
961+
*/
962+
public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR):array {
963+
$query = $this->db->getQueryBuilder();
964+
$query->select(['id','uid', 'etag', 'uri', 'calendardata'])
965+
->from('calendarobjects')
966+
->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)))
967+
->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType)))
968+
->andWhere($query->expr()->isNull('deleted_at'));
969+
$stmt = $query->executeQuery();
970+
971+
$result = [];
972+
while (($row = $stmt->fetch()) !== false) {
973+
$result[$row['uid']] = [
974+
'id' => $row['id'],
975+
'etag' => $row['etag'],
976+
'uri' => $row['uri'],
977+
'calendardata' => $row['calendardata'],
978+
];
979+
}
980+
$stmt->closeCursor();
981+
982+
return $result;
983+
}
984+
948985
/**
949986
* Delete all of an user's shares
950987
*
@@ -3264,6 +3301,45 @@ public function purgeAllCachedEventsForSubscription($subscriptionId) {
32643301
}, $this->db);
32653302
}
32663303

3304+
/**
3305+
* @param int $subscriptionId
3306+
* @param array<int> $calendarObjectIds
3307+
* @param array<string> $calendarObjectUris
3308+
*/
3309+
public function purgeCachedEventsForSubscription(int $subscriptionId, array $calendarObjectIds, array $calendarObjectUris): void {
3310+
if(empty($calendarObjectUris)) {
3311+
return;
3312+
}
3313+
3314+
$this->atomic(function () use ($subscriptionId, $calendarObjectIds, $calendarObjectUris) {
3315+
foreach (array_chunk($calendarObjectIds, 1000) as $chunk) {
3316+
$query = $this->db->getQueryBuilder();
3317+
$query->delete($this->dbObjectPropertiesTable)
3318+
->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId)))
3319+
->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)))
3320+
->andWhere($query->expr()->in('id', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY))
3321+
->executeStatement();
3322+
3323+
$query = $this->db->getQueryBuilder();
3324+
$query->delete('calendarobjects')
3325+
->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId)))
3326+
->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)))
3327+
->andWhere($query->expr()->in('id', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY))
3328+
->executeStatement();
3329+
}
3330+
3331+
foreach (array_chunk($calendarObjectUris, 1000) as $chunk) {
3332+
$query = $this->db->getQueryBuilder();
3333+
$query->delete('calendarchanges')
3334+
->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId)))
3335+
->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)))
3336+
->andWhere($query->expr()->in('uri', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY))
3337+
->executeStatement();
3338+
}
3339+
$this->addChanges($subscriptionId, $calendarObjectUris, 3, self::CALENDAR_TYPE_SUBSCRIPTION);
3340+
}, $this->db);
3341+
}
3342+
32673343
/**
32683344
* Move a calendar from one user to another
32693345
*
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OCA\DAV\CalDAV\WebcalCaching;
10+
11+
use Exception;
12+
use GuzzleHttp\HandlerStack;
13+
use GuzzleHttp\Middleware;
14+
use OCP\Http\Client\IClientService;
15+
use OCP\Http\Client\LocalServerException;
16+
use OCP\IAppConfig;
17+
use Psr\Http\Message\RequestInterface;
18+
use Psr\Http\Message\ResponseInterface;
19+
use Psr\Log\LoggerInterface;
20+
use Sabre\DAV\Xml\Property\Href;
21+
use Sabre\VObject\Reader;
22+
23+
class Connection {
24+
public function __construct(private IClientService $clientService,
25+
private IAppConfig $config,
26+
private LoggerInterface $logger) {
27+
}
28+
29+
/**
30+
* gets webcal feed from remote server
31+
*/
32+
public function queryWebcalFeed(array $subscription, array &$mutations): ?string {
33+
$client = $this->clientService->newClient();
34+
35+
$didBreak301Chain = false;
36+
$latestLocation = null;
37+
38+
$handlerStack = HandlerStack::create();
39+
$handlerStack->push(Middleware::mapRequest(function (RequestInterface $request) {
40+
return $request
41+
->withHeader('Accept', 'text/calendar, application/calendar+json, application/calendar+xml')
42+
->withHeader('User-Agent', 'Nextcloud Webcal Service');
43+
}));
44+
$handlerStack->push(Middleware::mapResponse(function (ResponseInterface $response) use (&$didBreak301Chain, &$latestLocation) {
45+
if (!$didBreak301Chain) {
46+
if ($response->getStatusCode() !== 301) {
47+
$didBreak301Chain = true;
48+
} else {
49+
$latestLocation = $response->getHeader('Location');
50+
}
51+
}
52+
return $response;
53+
}));
54+
55+
$allowLocalAccess = $this->config->getValueString('dav', 'webcalAllowLocalAccess', 'no');
56+
$subscriptionId = $subscription['id'];
57+
$url = $this->cleanURL($subscription['source']);
58+
if ($url === null) {
59+
return null;
60+
}
61+
62+
try {
63+
$params = [
64+
'allow_redirects' => [
65+
'redirects' => 10
66+
],
67+
'handler' => $handlerStack,
68+
'nextcloud' => [
69+
'allow_local_address' => $allowLocalAccess === 'yes',
70+
]
71+
];
72+
73+
$user = parse_url($subscription['source'], PHP_URL_USER);
74+
$pass = parse_url($subscription['source'], PHP_URL_PASS);
75+
if ($user !== null && $pass !== null) {
76+
$params['auth'] = [$user, $pass];
77+
}
78+
79+
$response = $client->get($url, $params);
80+
$body = $response->getBody();
81+
82+
if ($latestLocation !== null) {
83+
$mutations['{http://calendarserver.org/ns/}source'] = new Href($latestLocation);
84+
}
85+
86+
$contentType = $response->getHeader('Content-Type');
87+
$contentType = explode(';', $contentType, 2)[0];
88+
switch ($contentType) {
89+
case 'application/calendar+json':
90+
try {
91+
$jCalendar = Reader::readJson($body, Reader::OPTION_FORGIVING);
92+
} catch (Exception $ex) {
93+
// In case of a parsing error return null
94+
$this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]);
95+
return null;
96+
}
97+
return $jCalendar->serialize();
98+
99+
case 'application/calendar+xml':
100+
try {
101+
$xCalendar = Reader::readXML($body);
102+
} catch (Exception $ex) {
103+
// In case of a parsing error return null
104+
$this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]);
105+
return null;
106+
}
107+
return $xCalendar->serialize();
108+
109+
case 'text/calendar':
110+
default:
111+
try {
112+
$vCalendar = Reader::read($body);
113+
} catch (Exception $ex) {
114+
// In case of a parsing error return null
115+
$this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]);
116+
return null;
117+
}
118+
return $vCalendar->serialize();
119+
}
120+
} catch (LocalServerException $ex) {
121+
$this->logger->warning("Subscription $subscriptionId was not refreshed because it violates local access rules", [
122+
'exception' => $ex,
123+
]);
124+
125+
return null;
126+
} catch (Exception $ex) {
127+
$this->logger->warning("Subscription $subscriptionId could not be refreshed due to a network error", [
128+
'exception' => $ex,
129+
]);
130+
131+
return null;
132+
}
133+
}
134+
135+
/**
136+
* This method will strip authentication information and replace the
137+
* 'webcal' or 'webcals' protocol scheme
138+
*
139+
* @param string $url
140+
* @return string|null
141+
*/
142+
private function cleanURL(string $url): ?string {
143+
$parsed = parse_url($url);
144+
if ($parsed === false) {
145+
return null;
146+
}
147+
148+
if (isset($parsed['scheme']) && $parsed['scheme'] === 'http') {
149+
$scheme = 'http';
150+
} else {
151+
$scheme = 'https';
152+
}
153+
154+
$host = $parsed['host'] ?? '';
155+
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
156+
$path = $parsed['path'] ?? '';
157+
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
158+
$fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : '';
159+
160+
$cleanURL = "$scheme://$host$port$path$query$fragment";
161+
// parse_url is giving some weird results if no url and no :// is given,
162+
// so let's test the url again
163+
$parsedClean = parse_url($cleanURL);
164+
if ($parsedClean === false || !isset($parsedClean['host'])) {
165+
return null;
166+
}
167+
168+
return $cleanURL;
169+
}
170+
}

0 commit comments

Comments
 (0)