Skip to content

Commit 5e1202c

Browse files
fix: calendar subscription memory exhaustion
Signed-off-by: SebastianKrupinski <[email protected]>
1 parent ad4c566 commit 5e1202c

File tree

11 files changed

+968
-338
lines changed

11 files changed

+968
-338
lines changed

apps/dav/composer/composer/autoload_classmap.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@
6767
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => $baseDir . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
6868
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => $baseDir . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
6969
'OCA\\DAV\\CalDAV\\IRestorable' => $baseDir . '/../lib/CalDAV/IRestorable.php',
70+
'OCA\\DAV\\CalDAV\\Import\\ImportService' => $baseDir . '/../lib/CalDAV/Import/ImportService.php',
71+
'OCA\\DAV\\CalDAV\\Import\\TextImporter' => $baseDir . '/../lib/CalDAV/Import/TextImporter.php',
72+
'OCA\\DAV\\CalDAV\\Import\\XmlImporter' => $baseDir . '/../lib/CalDAV/Import/XmlImporter.php',
7073
'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => $baseDir . '/../lib/CalDAV/Integration/ExternalCalendar.php',
7174
'OCA\\DAV\\CalDAV\\Integration\\ICalendarProvider' => $baseDir . '/../lib/CalDAV/Integration/ICalendarProvider.php',
7275
'OCA\\DAV\\CalDAV\\InvitationResponse\\InvitationResponseServer' => $baseDir . '/../lib/CalDAV/InvitationResponse/InvitationResponseServer.php',

apps/dav/composer/composer/autoload_static.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ class ComposerStaticInitDAV
8282
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => __DIR__ . '/..' . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
8383
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
8484
'OCA\\DAV\\CalDAV\\IRestorable' => __DIR__ . '/..' . '/../lib/CalDAV/IRestorable.php',
85+
'OCA\\DAV\\CalDAV\\Import\\ImportService' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportService.php',
86+
'OCA\\DAV\\CalDAV\\Import\\TextImporter' => __DIR__ . '/..' . '/../lib/CalDAV/Import/TextImporter.php',
87+
'OCA\\DAV\\CalDAV\\Import\\XmlImporter' => __DIR__ . '/..' . '/../lib/CalDAV/Import/XmlImporter.php',
8588
'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => __DIR__ . '/..' . '/../lib/CalDAV/Integration/ExternalCalendar.php',
8689
'OCA\\DAV\\CalDAV\\Integration\\ICalendarProvider' => __DIR__ . '/..' . '/../lib/CalDAV/Integration/ICalendarProvider.php',
8790
'OCA\\DAV\\CalDAV\\InvitationResponse\\InvitationResponseServer' => __DIR__ . '/..' . '/../lib/CalDAV/InvitationResponse/InvitationResponseServer.php',

apps/dav/lib/CalDAV/CalDavBackend.php

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -982,9 +982,9 @@ public function restoreCalendar(int $id): void {
982982
* @param int $calendarType
983983
* @return array
984984
*/
985-
public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR):array {
985+
public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR, array $fields = []):array {
986986
$query = $this->db->getQueryBuilder();
987-
$query->select(['id','uid', 'etag', 'uri', 'calendardata'])
987+
$query->select($fields ?: ['id', 'uid', 'etag', 'uri', 'calendardata'])
988988
->from('calendarobjects')
989989
->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)))
990990
->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType)))
@@ -993,12 +993,7 @@ public function getLimitedCalendarObjects(int $calendarId, int $calendarType = s
993993

994994
$result = [];
995995
while (($row = $stmt->fetch()) !== false) {
996-
$result[$row['uid']] = [
997-
'id' => $row['id'],
998-
'etag' => $row['etag'],
999-
'uri' => $row['uri'],
1000-
'calendardata' => $row['calendardata'],
1001-
];
996+
$result[$row['uid']] = $row;
1002997
}
1003998
$stmt->closeCursor();
1004999

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
namespace OCA\DAV\CalDAV\Import;
9+
10+
use Generator;
11+
use InvalidArgumentException;
12+
use OCA\DAV\CalDAV\CalDavBackend;
13+
use Sabre\VObject\Component\VCalendar;
14+
use Sabre\VObject\Reader;
15+
16+
/**
17+
* Calendar Import Service
18+
*/
19+
class ImportService {
20+
21+
public function __construct(
22+
private CalDavBackend $backend,
23+
) {
24+
}
25+
26+
/**
27+
* Generates object stream from a text formatted source (ical)
28+
*
29+
* @param resource $source
30+
*
31+
* @return Generator<\Sabre\VObject\Component\VCalendar>
32+
*/
33+
public function importText($source): Generator {
34+
if (!is_resource($source)) {
35+
throw new InvalidArgumentException('Invalid import source must be a file resource');
36+
}
37+
$importer = new TextImporter($source);
38+
$structure = $importer->structure();
39+
$sObjectPrefix = $importer::OBJECT_PREFIX;
40+
$sObjectSuffix = $importer::OBJECT_SUFFIX;
41+
// calendar properties
42+
foreach ($structure['VCALENDAR'] as $entry) {
43+
if (!str_ends_with($entry, "\n") || !str_ends_with($entry, "\r\n")) {
44+
$sObjectPrefix .= PHP_EOL;
45+
}
46+
}
47+
// calendar time zones
48+
$timezones = [];
49+
foreach ($structure['VTIMEZONE'] as $tid => $collection) {
50+
$instance = $collection[0];
51+
$sObjectContents = $importer->extract((int)$instance[2], (int)$instance[3]);
52+
$vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix);
53+
$timezones[$tid] = clone $vObject->VTIMEZONE;
54+
}
55+
// calendar components
56+
// for each component type, construct a full calendar object with all components
57+
// that match the same UID and appropriate time zones that are used in the components
58+
foreach (['VEVENT', 'VTODO', 'VJOURNAL'] as $type) {
59+
foreach ($structure[$type] as $cid => $instances) {
60+
/** @var array<int,VCalendar> $instances */
61+
// extract all instances of component and unserialize to object
62+
$sObjectContents = '';
63+
foreach ($instances as $instance) {
64+
$sObjectContents .= $importer->extract($instance[2], $instance[3]);
65+
}
66+
/** @var VCalendar $vObject */
67+
$vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix);
68+
// add time zones to object
69+
foreach ($this->findTimeZones($vObject) as $zone) {
70+
if (isset($timezones[$zone])) {
71+
$vObject->add(clone $timezones[$zone]);
72+
}
73+
}
74+
yield $vObject;
75+
}
76+
}
77+
}
78+
79+
/**
80+
* Generates object stream from a xml formatted source (xcal)
81+
*
82+
* @param resource $source
83+
*
84+
* @return Generator<\Sabre\VObject\Component\VCalendar>
85+
*/
86+
public function importXml($source): Generator {
87+
if (!is_resource($source)) {
88+
throw new InvalidArgumentException('Invalid import source must be a file resource');
89+
}
90+
$importer = new XmlImporter($source);
91+
$structure = $importer->structure();
92+
$sObjectPrefix = $importer::OBJECT_PREFIX;
93+
$sObjectSuffix = $importer::OBJECT_SUFFIX;
94+
// calendar time zones
95+
$timezones = [];
96+
foreach ($structure['VTIMEZONE'] as $tid => $collection) {
97+
$instance = $collection[0];
98+
$sObjectContents = $importer->extract((int)$instance[2], (int)$instance[3]);
99+
$vObject = Reader::readXml($sObjectPrefix . $sObjectContents . $sObjectSuffix);
100+
$timezones[$tid] = clone $vObject->VTIMEZONE;
101+
}
102+
// calendar components
103+
// for each component type, construct a full calendar object with all components
104+
// that match the same UID and appropriate time zones that are used in the components
105+
foreach (['VEVENT', 'VTODO', 'VJOURNAL'] as $type) {
106+
foreach ($structure[$type] as $cid => $instances) {
107+
/** @var array<int,VCalendar> $instances */
108+
// extract all instances of component and unserialize to object
109+
$sObjectContents = '';
110+
foreach ($instances as $instance) {
111+
$sObjectContents .= $importer->extract($instance[2], $instance[3]);
112+
}
113+
/** @var VCalendar $vObject */
114+
$vObject = Reader::readXml($sObjectPrefix . $sObjectContents . $sObjectSuffix);
115+
// add time zones to object
116+
foreach ($this->findTimeZones($vObject) as $zone) {
117+
if (isset($timezones[$zone])) {
118+
$vObject->add(clone $timezones[$zone]);
119+
}
120+
}
121+
yield $vObject;
122+
}
123+
}
124+
}
125+
126+
/**
127+
* Generates object stream from a json formatted source (jcal)
128+
*
129+
* @param resource $source
130+
*
131+
* @return Generator<\Sabre\VObject\Component\VCalendar>
132+
*/
133+
public function importJson($source): Generator {
134+
if (!is_resource($source)) {
135+
throw new InvalidArgumentException('Invalid import source must be a file resource');
136+
}
137+
/** @var VCALENDAR $importer */
138+
$importer = Reader::readJson($source);
139+
// calendar time zones
140+
$timezones = [];
141+
foreach ($importer->VTIMEZONE as $timezone) {
142+
$tzid = $timezone->TZID?->getValue();
143+
if ($tzid !== null) {
144+
$timezones[$tzid] = clone $timezone;
145+
}
146+
}
147+
// calendar components
148+
foreach ($importer->getBaseComponents() as $base) {
149+
$vObject = new VCalendar;
150+
$vObject->VERSION = clone $importer->VERSION;
151+
$vObject->PRODID = clone $importer->PRODID;
152+
// extract all instances of component
153+
foreach ($importer->getByUID($base->UID->getValue()) as $instance) {
154+
$vObject->add(clone $instance);
155+
}
156+
// add time zones to object
157+
foreach ($this->findTimeZones($vObject) as $zone) {
158+
if (isset($timezones[$zone])) {
159+
$vObject->add(clone $timezones[$zone]);
160+
}
161+
}
162+
yield $vObject;
163+
}
164+
}
165+
166+
/**
167+
* Searches through all component properties looking for defined timezones
168+
*
169+
* @return array<string>
170+
*/
171+
private function findTimeZones(VCalendar $vObject): array {
172+
$timezones = [];
173+
foreach ($vObject->getComponents() as $vComponent) {
174+
if ($vComponent->name !== 'VTIMEZONE') {
175+
foreach (['DTSTART', 'DTEND', 'DUE', 'RDATE', 'EXDATE'] as $property) {
176+
if (isset($vComponent->$property?->parameters['TZID'])) {
177+
$tid = $vComponent->$property->parameters['TZID']->getValue();
178+
$timezones[$tid] = true;
179+
}
180+
}
181+
}
182+
}
183+
return array_keys($timezones);
184+
}
185+
186+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
namespace OCA\DAV\CalDAV\Import;
9+
10+
use Exception;
11+
12+
class TextImporter {
13+
14+
public const OBJECT_PREFIX = 'BEGIN:VCALENDAR' . PHP_EOL;
15+
public const OBJECT_SUFFIX = PHP_EOL . 'END:VCALENDAR';
16+
private const COMPONENT_TYPES = ['VEVENT', 'VTODO', 'VJOURNAL', 'VTIMEZONE'];
17+
18+
private bool $analyzed = false;
19+
private array $structure = ['VCALENDAR' => [], 'VEVENT' => [], 'VTODO' => [], 'VJOURNAL' => [], 'VTIMEZONE' => []];
20+
21+
/**
22+
* @param resource $source
23+
*/
24+
public function __construct(
25+
private $source,
26+
) {
27+
// Ensure that source is a stream resource
28+
if (!is_resource($source) || get_resource_type($source) !== 'stream') {
29+
throw new Exception('Source must be a stream resource');
30+
}
31+
}
32+
33+
/**
34+
* Analyzes the source data and creates a structure of components
35+
*/
36+
private function analyze() {
37+
$componentStart = null;
38+
$componentEnd = null;
39+
$componentId = null;
40+
$componentType = null;
41+
$tagName = null;
42+
$tagValue = null;
43+
44+
// iterate through the source data line by line
45+
fseek($this->source, 0);
46+
while (!feof($this->source)) {
47+
$data = fgets($this->source);
48+
// skip empty lines
49+
if ($data === false || empty(trim($data))) {
50+
continue;
51+
}
52+
// lines with whitespace at the beginning are continuations of the previous line
53+
if (ctype_space($data[0]) === false) {
54+
// detect the line TAG
55+
// detect the first occurrence of ':' or ';'
56+
$colonPos = strpos($data, ':');
57+
$semicolonPos = strpos($data, ';');
58+
if ($colonPos !== false && $semicolonPos !== false) {
59+
$splitPosition = min($colonPos, $semicolonPos);
60+
} elseif ($colonPos !== false) {
61+
$splitPosition = $colonPos;
62+
} elseif ($semicolonPos !== false) {
63+
$splitPosition = $semicolonPos;
64+
} else {
65+
continue;
66+
}
67+
$tagName = strtoupper(trim(substr($data, 0, $splitPosition)));
68+
$tagValue = trim(substr($data, $splitPosition + 1));
69+
$tagContinuation = false;
70+
} else {
71+
$tagContinuation = true;
72+
$tagValue .= trim($data);
73+
}
74+
75+
if ($tagContinuation === false) {
76+
// check line for component start, remember the position and determine the type
77+
if ($tagName === 'BEGIN' && in_array($tagValue, self::COMPONENT_TYPES, true)) {
78+
$componentStart = ftell($this->source) - strlen($data);
79+
$componentType = $tagValue;
80+
}
81+
// check line for component end, remember the position
82+
if ($tagName === 'END' && $componentType === $tagValue) {
83+
$componentEnd = ftell($this->source);
84+
}
85+
// check line for component id
86+
if ($componentStart !== null && ($tagName === 'UID' || $tagName === 'TZID')) {
87+
$componentId = $tagValue;
88+
}
89+
} else {
90+
// check line for component id
91+
if ($componentStart !== null && ($tagName === 'UID' || $tagName === 'TZID')) {
92+
$componentId = $tagValue;
93+
}
94+
}
95+
// any line(s) not inside a component are VCALENDAR properties
96+
if ($componentStart === null) {
97+
if ($tagName !== 'BEGIN' && $tagName !== 'END' && $tagValue === 'VCALENDAR') {
98+
$components['VCALENDAR'][] = $data;
99+
}
100+
}
101+
// if component start and end are found, add the component to the structure
102+
if ($componentStart !== null && $componentEnd !== null) {
103+
if ($componentId !== null) {
104+
$this->structure[$componentType][$componentId][] = [
105+
$componentType,
106+
$componentId,
107+
$componentStart,
108+
$componentEnd
109+
];
110+
} else {
111+
$this->structure[$componentType][] = [
112+
$componentType,
113+
$componentId,
114+
$componentStart,
115+
$componentEnd
116+
];
117+
}
118+
$componentId = null;
119+
$componentType = null;
120+
$componentStart = null;
121+
$componentEnd = null;
122+
}
123+
}
124+
}
125+
126+
/**
127+
* Returns the analyzed structure of the source data
128+
* the analyzed structure is a collection of components organized by type,
129+
* each entry is a collection of instances
130+
* [
131+
* 'VEVENT' => [
132+
* '7456f141-b478-4cb9-8efc-1427ba0d6839' => [
133+
* ['VEVENT', '7456f141-b478-4cb9-8efc-1427ba0d6839', 0, 100 ],
134+
* ['VEVENT', '7456f141-b478-4cb9-8efc-1427ba0d6839', 100, 200 ]
135+
* ]
136+
* ]
137+
* ]
138+
*/
139+
public function structure(): array {
140+
if (!$this->analyzed) {
141+
$this->analyze();
142+
}
143+
return $this->structure;
144+
}
145+
146+
/**
147+
* Extracts a string chuck from the source data
148+
*
149+
* @param int $start starting byte position
150+
* @param int $end ending byte position
151+
*/
152+
public function extract(int $start, int $end): string {
153+
fseek($this->source, $start);
154+
return fread($this->source, $end - $start);
155+
}
156+
}

0 commit comments

Comments
 (0)