Skip to content

Commit 6d87b22

Browse files
committed
feat: tracking issues with calendar subscriptions
Signed-off-by: Roberto Guido <info@madbob.org>
1 parent d3aba32 commit 6d87b22

8 files changed

Lines changed: 146 additions & 36 deletions

File tree

apps/dav/lib/CalDAV/CalDavBackend.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3160,6 +3160,29 @@ public function deleteSubscription($subscriptionId) {
31603160
}, $this->db);
31613161
}
31623162

3163+
/**
3164+
* Update the error status of a subscription
3165+
*
3166+
* @param mixed $subscriptionId
3167+
* @param null|Exception $exception
3168+
* @param null|string $error
3169+
* @return void
3170+
*/
3171+
public function trackSubscriptionError($subscriptionId, $exception, $error): void {
3172+
$query = $this->db->getQueryBuilder();
3173+
$query->update('calendarsubscriptions')
3174+
->set('lasterror', $query->createNamedParameter($error))
3175+
->executeStatement();
3176+
3177+
if ($error) {
3178+
$this->logger->error('Subscription {subscriptionId} could not be refreshed: {error}', [
3179+
'exception' => $exception,
3180+
'subscriptionId' => $subscriptionId,
3181+
'error' => $error,
3182+
]);
3183+
}
3184+
}
3185+
31633186
/**
31643187
* Returns a single scheduling object for the inbox collection.
31653188
*

apps/dav/lib/CalDAV/WebcalCaching/Connection.php

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,16 @@
99

1010
namespace OCA\DAV\CalDAV\WebcalCaching;
1111

12-
use Exception;
1312
use GuzzleHttp\RequestOptions;
13+
use OCA\DAV\Exception\InvalidSubscriptionPayload;
14+
use OCA\DAV\Exception\InvalidSubscriptionUrl;
1415
use OCP\Http\Client\IClientService;
15-
use OCP\Http\Client\LocalServerException;
1616
use OCP\IAppConfig;
17-
use Psr\Log\LoggerInterface;
1817

1918
class Connection {
2019
public function __construct(
2120
private IClientService $clientService,
2221
private IAppConfig $config,
23-
private LoggerInterface $logger,
2422
) {
2523
}
2624

@@ -33,7 +31,7 @@ public function queryWebcalFeed(array $subscription): ?array {
3331
$subscriptionId = $subscription['id'];
3432
$url = $this->cleanURL($subscription['source']);
3533
if ($url === null) {
36-
return null;
34+
throw new InvalidSubscriptionUrl('Wrong URL: ' . $subscription['source']);
3735
}
3836

3937
// ICS feeds hosted on O365 can return HTTP 500 when the UA string isn't satisfactory
@@ -65,20 +63,8 @@ public function queryWebcalFeed(array $subscription): ?array {
6563
$params[RequestOptions::AUTH] = [$user, $pass];
6664
}
6765

68-
try {
69-
$client = $this->clientService->newClient();
70-
$response = $client->get($url, $params);
71-
} catch (LocalServerException $ex) {
72-
$this->logger->warning("Subscription $subscriptionId was not refreshed because it violates local access rules", [
73-
'exception' => $ex,
74-
]);
75-
return null;
76-
} catch (Exception $ex) {
77-
$this->logger->warning("Subscription $subscriptionId could not be refreshed due to a network error", [
78-
'exception' => $ex,
79-
]);
80-
return null;
81-
}
66+
$client = $this->clientService->newClient();
67+
$response = $client->get($url, $params);
8268

8369
$contentType = $response->getHeader('Content-Type');
8470
$contentType = explode(';', $contentType, 2)[0];
@@ -92,7 +78,7 @@ public function queryWebcalFeed(array $subscription): ?array {
9278
// With 'stream' => true, getBody() returns the underlying stream resource
9379
$stream = $response->getBody();
9480
if (!is_resource($stream)) {
95-
return null;
81+
throw new InvalidSubscriptionPayload('Cannot open a stream for ' . $url);
9682
}
9783

9884
return ['data' => $stream, 'format' => $format];

apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@
99

1010
namespace OCA\DAV\CalDAV\WebcalCaching;
1111

12+
use Exception;
1213
use OCA\DAV\CalDAV\CalDavBackend;
1314
use OCA\DAV\CalDAV\Import\ImportService;
15+
use OCA\DAV\Exception\InvalidSubscriptionPayload;
16+
use OCA\DAV\Exception\InvalidSubscriptionUrl;
1417
use OCP\AppFramework\Utility\ITimeFactory;
18+
use OCP\Http\Client\LocalServerException;
1519
use Psr\Log\LoggerInterface;
1620
use Sabre\DAV\PropPatch;
1721
use Sabre\VObject\Component;
@@ -54,19 +58,18 @@ public function refreshSubscription(string $principalUri, string $uri) {
5458
}
5559
}
5660

57-
$result = $this->connection->queryWebcalFeed($subscription);
58-
if (!$result) {
59-
return;
60-
}
61+
$data = null;
62+
63+
try {
64+
$result = $this->connection->queryWebcalFeed($subscription);
6165

62-
$data = $result['data'];
63-
$format = $result['format'];
66+
$data = $result['data'];
67+
$format = $result['format'];
6468

65-
$stripTodos = ($subscription[self::STRIP_TODOS] ?? 1) === 1;
66-
$stripAlarms = ($subscription[self::STRIP_ALARMS] ?? 1) === 1;
67-
$stripAttachments = ($subscription[self::STRIP_ATTACHMENTS] ?? 1) === 1;
69+
$stripTodos = ($subscription[self::STRIP_TODOS] ?? 1) === 1;
70+
$stripAlarms = ($subscription[self::STRIP_ALARMS] ?? 1) === 1;
71+
$stripAttachments = ($subscription[self::STRIP_ATTACHMENTS] ?? 1) === 1;
6872

69-
try {
7073
$existingObjects = $this->calDavBackend->getLimitedCalendarObjects((int)$subscription['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION, ['id', 'uid', 'etag', 'uri']);
7174

7275
$generator = match ($format) {
@@ -158,11 +161,21 @@ public function refreshSubscription(string $principalUri, string $uri) {
158161
if (isset($vObject)) {
159162
$this->updateRefreshRate($subscription, $vObject);
160163
}
164+
165+
$this->calDavBackend->trackSubscriptionError($subscription['id'], null, null);
161166
} catch (ParseException $ex) {
162-
$this->logger->error('Subscription {subscriptionId} could not be refreshed due to a parsing error', ['exception' => $ex, 'subscriptionId' => $subscription['id']]);
167+
$this->calDavBackend->trackSubscriptionError($subscription['id'], $ex, 'Parsing error');
168+
} catch (LocalServerException $ex) {
169+
$this->calDavBackend->trackSubscriptionError($subscription['id'], $ex, 'Subscription violates local access rules');
170+
} catch (InvalidSubscriptionUrl $ex) {
171+
$this->calDavBackend->trackSubscriptionError($subscription['id'], $ex, 'Invalid URL');
172+
} catch (InvalidSubscriptionPayload $ex) {
173+
$this->calDavBackend->trackSubscriptionError($subscription['id'], $ex, 'Invalid contents');
174+
} catch (Exception $ex) {
175+
$this->calDavBackend->trackSubscriptionError($subscription['id'], $ex, 'Network error');
163176
} finally {
164177
// Close the data stream to free resources
165-
if (is_resource($data)) {
178+
if ($data && is_resource($data)) {
166179
fclose($data);
167180
}
168181
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\DAV\Exception;
11+
12+
use Exception;
13+
14+
class InvalidSubscriptionPayload extends Exception {
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\DAV\Exception;
11+
12+
use Exception;
13+
14+
class InvalidSubscriptionUrl extends Exception {
15+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\DAV\Migration;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\DB\Types;
15+
use OCP\Migration\IOutput;
16+
use OCP\Migration\SimpleMigrationStep;
17+
18+
/**
19+
* FIXME Auto-generated migration step: Please modify to your needs!
20+
*/
21+
class Version2000Date20260815202617 extends SimpleMigrationStep {
22+
/**
23+
* @param IOutput $output
24+
* @param Closure(): ISchemaWrapper $schemaClosure
25+
* @param array $options
26+
* @return null|ISchemaWrapper
27+
*/
28+
#[Override]
29+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
30+
/** @var ISchemaWrapper $schema */
31+
$schema = $schemaClosure();
32+
33+
$table = $schema->getTable('calendarsubscriptions');
34+
35+
if (!$table->hasColumn('lasterror')) {
36+
$table->addColumn('lasterror', Types::STRING, [
37+
'notnull' => false,
38+
'length' => 255,
39+
'default' => null,
40+
]);
41+
}
42+
43+
return $schema;
44+
}
45+
}

apps/dav/tests/unit/CalDAV/WebcalCaching/ConnectionTest.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
namespace OCA\DAV\Tests\unit\CalDAV\WebcalCaching;
1010

1111
use OCA\DAV\CalDAV\WebcalCaching\Connection;
12+
use OCA\DAV\Exception\InvalidSubscriptionPayload;
13+
use OCA\DAV\Exception\InvalidSubscriptionUrl;
1214
use OCP\Http\Client\IClient;
1315
use OCP\Http\Client\IClientService;
1416
use OCP\Http\Client\IResponse;
@@ -60,10 +62,8 @@ public function testLocalUrl($source): void {
6062
$client->expects(self::once())
6163
->method('get')
6264
->willThrowException($localServerException);
63-
$this->logger->expects(self::once())
64-
->method('warning')
65-
->with('Subscription 42 was not refreshed because it violates local access rules', ['exception' => $localServerException]);
6665

66+
$this->expectException(LocalServerException::class);
6767
$this->connection->queryWebcalFeed($subscription);
6868
}
6969

@@ -85,6 +85,7 @@ public function testInvalidUrl(): void {
8585
$client->expects(self::never())
8686
->method('get');
8787

88+
$this->expectException(InvalidSubscriptionUrl::class);
8889
$this->connection->queryWebcalFeed($subscription);
8990

9091
}
@@ -186,6 +187,7 @@ public function testConnectionReturnsNullWhenBodyIsNotResource(): void {
186187
->method('getBody')
187188
->willReturn('not a resource');
188189

190+
$this->expectException(InvalidSubscriptionPayload::class);
189191
$output = $this->connection->queryWebcalFeed($subscription);
190192

191193
$this->assertNull($output);

apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use OCA\DAV\CalDAV\Import\ImportService;
1313
use OCA\DAV\CalDAV\WebcalCaching\Connection;
1414
use OCA\DAV\CalDAV\WebcalCaching\RefreshWebcalService;
15+
use OCA\DAV\Exception\InvalidSubscriptionUrl;
1516
use OCP\AppFramework\Utility\ITimeFactory;
1617
use PHPUnit\Framework\MockObject\MockObject;
1718
use Psr\Log\LoggerInterface;
@@ -92,6 +93,10 @@ public function testRun(string $body, string $format, string $result): void {
9293
->method('queryWebcalFeed')
9394
->willReturn(['data' => $stream, 'format' => $format]);
9495

96+
$this->caldavBackend->expects(self::once())
97+
->method('trackSubscriptionError')
98+
->with('42', null, null);
99+
95100
$this->caldavBackend->expects(self::once())
96101
->method('getLimitedCalendarObjects')
97102
->willReturn([]);
@@ -226,9 +231,15 @@ public function testConnectionReturnsNull(): void {
226231
],
227232
]);
228233

234+
$exception = new InvalidSubscriptionUrl('broken');
235+
229236
$this->connection->expects(self::once())
230237
->method('queryWebcalFeed')
231-
->willReturn(null);
238+
->willThrowException($exception);
239+
240+
$this->caldavBackend->expects(self::once())
241+
->method('trackSubscriptionError')
242+
->with('42', $exception, 'Invalid URL');
232243

233244
$this->importService->expects(self::never())
234245
->method('importText');

0 commit comments

Comments
 (0)