fix: calendar subscription memory exhaustion

Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
pull/56784/head
SebastianKrupinski 2025-11-27 09:56:01 +07:00
parent 4185dfb599
commit 1a0535aa75
7 changed files with 476 additions and 329 deletions

@ -1066,9 +1066,9 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param int $calendarType
* @return array
*/
public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR):array {
public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR, array $fields = []):array {
$query = $this->db->getQueryBuilder();
$query->select(['id','uid', 'etag', 'uri', 'calendardata'])
$query->select($fields ?: ['id', 'uid', 'etag', 'uri', 'calendardata'])
->from('calendarobjects')
->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)))
->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType)))
@ -1077,12 +1077,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
$result = [];
while (($row = $stmt->fetchAssociative()) !== false) {
$result[$row['uid']] = [
'id' => $row['id'],
'etag' => $row['etag'],
'uri' => $row['uri'],
'calendardata' => $row['calendardata'],
];
$result[$row['uid']] = $row;
}
$stmt->closeCursor();

@ -23,9 +23,6 @@ use Sabre\VObject\UUIDUtil;
*/
class ImportService {
/** @var resource */
private $source;
public function __construct(
private CalDavBackend $backend,
) {
@ -44,18 +41,15 @@ class ImportService {
if (!is_resource($source)) {
throw new InvalidArgumentException('Invalid import source must be a file resource');
}
$this->source = $source;
switch ($options->getFormat()) {
case 'ical':
return $this->importProcess($calendar, $options, $this->importText(...));
return $this->importProcess($source, $calendar, $options, $this->importText(...));
break;
case 'jcal':
return $this->importProcess($calendar, $options, $this->importJson(...));
return $this->importProcess($source, $calendar, $options, $this->importJson(...));
break;
case 'xcal':
return $this->importProcess($calendar, $options, $this->importXml(...));
return $this->importProcess($source, $calendar, $options, $this->importXml(...));
break;
default:
throw new InvalidArgumentException('Invalid import format');
@ -65,10 +59,15 @@ class ImportService {
/**
* Generates object stream from a text formatted source (ical)
*
* @param resource $source
*
* @return Generator<\Sabre\VObject\Component\VCalendar>
*/
private function importText(): Generator {
$importer = new TextImporter($this->source);
public function importText($source): Generator {
if (!is_resource($source)) {
throw new InvalidArgumentException('Invalid import source must be a file resource');
}
$importer = new TextImporter($source);
$structure = $importer->structure();
$sObjectPrefix = $importer::OBJECT_PREFIX;
$sObjectSuffix = $importer::OBJECT_SUFFIX;
@ -113,10 +112,15 @@ class ImportService {
/**
* Generates object stream from a xml formatted source (xcal)
*
* @param resource $source
*
* @return Generator<\Sabre\VObject\Component\VCalendar>
*/
private function importXml(): Generator {
$importer = new XmlImporter($this->source);
public function importXml($source): Generator {
if (!is_resource($source)) {
throw new InvalidArgumentException('Invalid import source must be a file resource');
}
$importer = new XmlImporter($source);
$structure = $importer->structure();
$sObjectPrefix = $importer::OBJECT_PREFIX;
$sObjectSuffix = $importer::OBJECT_SUFFIX;
@ -155,11 +159,16 @@ class ImportService {
/**
* Generates object stream from a json formatted source (jcal)
*
* @param resource $source
*
* @return Generator<\Sabre\VObject\Component\VCalendar>
*/
private function importJson(): Generator {
public function importJson($source): Generator {
if (!is_resource($source)) {
throw new InvalidArgumentException('Invalid import source must be a file resource');
}
/** @var VCALENDAR $importer */
$importer = Reader::readJson($this->source);
$importer = Reader::readJson($source);
// calendar time zones
$timezones = [];
foreach ($importer->VTIMEZONE as $timezone) {
@ -212,17 +221,18 @@ class ImportService {
*
* @since 32.0.0
*
* @param resource $source
* @param CalendarImportOptions $options
* @param callable $generator<CalendarImportOptions>: Generator<\Sabre\VObject\Component\VCalendar>
*
* @return array<string,array<string,string|array<string>>>
*/
public function importProcess(CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): array {
public function importProcess($source, CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): array {
$calendarId = $calendar->getKey();
$calendarUri = $calendar->getUri();
$principalUri = $calendar->getPrincipalUri();
$outcome = [];
foreach ($generator() as $vObject) {
foreach ($generator($source) as $vObject) {
$components = $vObject->getBaseComponents();
// determine if the object has no base component types
if (count($components) === 0) {

@ -14,7 +14,6 @@ use OCP\Http\Client\IClientService;
use OCP\Http\Client\LocalServerException;
use OCP\IAppConfig;
use Psr\Log\LoggerInterface;
use Sabre\VObject\Reader;
class Connection {
public function __construct(
@ -26,8 +25,10 @@ class Connection {
/**
* gets webcal feed from remote server
*
* @return array{data: resource, format: string}|null
*/
public function queryWebcalFeed(array $subscription): ?string {
public function queryWebcalFeed(array $subscription): ?array {
$subscriptionId = $subscription['id'];
$url = $this->cleanURL($subscription['source']);
if ($url === null) {
@ -54,6 +55,7 @@ class Connection {
'User-Agent' => $uaString,
'Accept' => 'text/calendar, application/calendar+json, application/calendar+xml',
],
'stream' => true,
];
$user = parse_url($subscription['source'], PHP_URL_USER);
@ -77,42 +79,22 @@ class Connection {
return null;
}
$body = $response->getBody();
$contentType = $response->getHeader('Content-Type');
$contentType = explode(';', $contentType, 2)[0];
switch ($contentType) {
case 'application/calendar+json':
try {
$jCalendar = Reader::readJson($body, Reader::OPTION_FORGIVING);
} catch (Exception $ex) {
// In case of a parsing error return null
$this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]);
return null;
}
return $jCalendar->serialize();
case 'application/calendar+xml':
try {
$xCalendar = Reader::readXML($body);
} catch (Exception $ex) {
// In case of a parsing error return null
$this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]);
return null;
}
return $xCalendar->serialize();
case 'text/calendar':
default:
try {
$vCalendar = Reader::read($body);
} catch (Exception $ex) {
// In case of a parsing error return null
$this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]);
return null;
}
return $vCalendar->serialize();
$format = match ($contentType) {
'application/calendar+json' => 'jcal',
'application/calendar+xml' => 'xcal',
default => 'ical',
};
// With 'stream' => true, getBody() returns the underlying stream resource
$stream = $response->getBody();
if (!is_resource($stream)) {
return null;
}
return ['data' => $stream, 'format' => $format];
}
/**

@ -9,18 +9,14 @@ declare(strict_types=1);
namespace OCA\DAV\CalDAV\WebcalCaching;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Import\ImportService;
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;
@ -36,20 +32,20 @@ class RefreshWebcalService {
private LoggerInterface $logger,
private Connection $connection,
private ITimeFactory $time,
private ImportService $importService,
) {
}
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']);
if (!empty($subscription[self::REFRESH_RATE])) {
// add the refresh interval to the last modified timestamp
$refreshInterval = new \DateInterval($subscription[self::REFRESH_RATE]);
$updateTime = $this->time->getDateTime();
$updateTime->setTimestamp($subscription['lastmodified'])->add($refreshInterval);
if ($updateTime->getTimestamp() > $this->time->getTime()) {
@ -57,109 +53,116 @@ class RefreshWebcalService {
}
}
$webcalData = $this->connection->queryWebcalFeed($subscription);
if (!$webcalData) {
$result = $this->connection->queryWebcalFeed($subscription);
if (!$result) {
return;
}
$localData = $this->calDavBackend->getLimitedCalendarObjects((int)$subscription['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
$data = $result['data'];
$format = $result['format'];
$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;
$existingObjects = $this->calDavBackend->getLimitedCalendarObjects((int)$subscription['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION, ['id', 'uid', 'etag', 'uri']);
if ($stripAlarms) {
unset($component->{'VALARM'});
}
if ($stripAttachments) {
unset($component->{'ATTACH'});
}
$uid = $component->{ 'UID' }->getValue();
}
if ($stripTodos && $compName === 'VTODO') {
continue;
}
$generator = match ($format) {
'xcal' => $this->importService->importXml(...),
'jcal' => $this->importService->importJson(...),
default => $this->importService->importText(...)
};
if (!isset($uid)) {
continue;
}
foreach ($generator($data) as $vObject) {
/** @var Component\VCalendar $vObject */
$vBase = $vObject->getBaseComponent();
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']]);
if (!$vBase->UID) {
continue;
}
// Find all identical sets and remove them from the update
if (isset($localData[$uid]) && $denormalized['etag'] === $localData[$uid]['etag']) {
unset($localData[$uid]);
// Some calendar providers (e.g. Google, MS) use very long UIDs
if (strlen($vBase->UID->getValue()) > 512) {
$this->logger->warning('Skipping calendar object with overly long UID from subscription "{subscriptionId}"', [
'subscriptionId' => $subscription['id'],
'uid' => $vBase->UID->getValue(),
]);
continue;
}
$vObjectCopy = clone $vObject;
$identical = isset($localData[$uid]) && $this->compareWithoutDtstamp($vObjectCopy, $localData[$uid]);
if ($identical) {
unset($localData[$uid]);
if ($stripTodos && $vBase->name === 'VTODO') {
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;
if ($stripAlarms || $stripAttachments) {
foreach ($vObject->getComponents() as $component) {
if ($component->name === 'VTIMEZONE') {
continue;
}
if ($stripAlarms) {
$component->remove('VALARM');
}
if ($stripAttachments) {
$component->remove('ATTACH');
}
}
}
// 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']]);
$sObject = $vObject->serialize();
$uid = $vBase->UID->getValue();
$etag = md5($sObject);
// No existing object with this UID, create it
if (!isset($existingObjects[$uid])) {
try {
$this->calDavBackend->createCalendarObject(
$subscription['id'],
UUIDUtil::getUUID() . '.ics',
$sObject,
CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION
);
} catch (\Exception $ex) {
$this->logger->warning('Unable to create calendar object from subscription {subscriptionId}', [
'exception' => $ex,
'subscriptionId' => $subscription['id'],
'source' => $subscription['source'],
]);
}
} elseif ($existingObjects[$uid]['etag'] !== $etag) {
// Existing object with this UID but different etag, update it
$this->calDavBackend->updateCalendarObject(
$subscription['id'],
$existingObjects[$uid]['uri'],
$sObject,
CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION
);
unset($existingObjects[$uid]);
} else {
// Existing object with same etag, just remove from tracking
unset($existingObjects[$uid]);
}
}
$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);
// Clean up objects that no longer exist in the remote feed
// The only events left over should be those not found upstream
if (!empty($existingObjects)) {
$ids = array_map('intval', array_column($existingObjects, 'id'));
$uris = array_column($existingObjects, 'uri');
$this->calDavBackend->purgeCachedEventsForSubscription((int)$subscription['id'], $ids, $uris);
}
$newRefreshRate = $this->checkWebcalDataForRefreshRate($subscription, $webcalData);
if ($newRefreshRate) {
$mutations[self::REFRESH_RATE] = $newRefreshRate;
// Update refresh rate from the last processed object
if (isset($vObject)) {
$this->updateRefreshRate($subscription, $vObject);
}
$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']]);
} finally {
// Close the data stream to free resources
if (is_resource($data)) {
fclose($data);
}
}
}
@ -181,84 +184,34 @@ class RefreshWebcalService {
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
*
* Update refresh rate from calendar object if:
* - current subscription does not store a refreshrate
* - the webcal feed suggests a valid refreshrate
*/
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;
private function updateRefreshRate(array $subscription, Component\VCalendar $vCalendar): void {
// if there is already a refreshrate stored in the database, don't override it
if (!empty($subscription[self::REFRESH_RATE])) {
return;
}
/** @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();
}
$refreshRate = $vCalendar->{'REFRESH-INTERVAL'}?->getValue()
?? $vCalendar->{'X-PUBLISHED-TTL'}?->getValue();
if (!$newRefreshRate) {
return null;
if ($refreshRate === null) {
return;
}
// check if new refresh rate is even valid
// check if refresh rate is 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)) {
DateTimeParser::parseDuration($refreshRate);
} catch (InvalidDataException) {
return;
}
$propPatch = new PropPatch($mutations);
$propPatch = new PropPatch([self::REFRESH_RATE => $refreshRate]);
$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;
}
}

@ -89,12 +89,8 @@ class ConnectionTest extends TestCase {
}
/**
* @param string $result
* @param string $contentType
*/
#[\PHPUnit\Framework\Attributes\DataProvider('urlDataProvider')]
public function testConnection(string $url, string $result, string $contentType): void {
public function testConnection(string $url, string $contentType, string $expectedFormat): void {
$client = $this->createMock(IClient::class);
$response = $this->createMock(IResponse::class);
$subscription = [
@ -123,16 +119,76 @@ class ConnectionTest extends TestCase {
->with('https://foo.bar/bla2')
->willReturn($response);
$response->expects($this->once())
->method('getHeader')
->with('Content-Type')
->willReturn($contentType);
// Create a stream resource to simulate streaming response
$stream = fopen('php://temp', 'r+');
fwrite($stream, 'test calendar data');
rewind($stream);
$response->expects($this->once())
->method('getBody')
->willReturn($stream);
$output = $this->connection->queryWebcalFeed($subscription);
$this->assertIsArray($output);
$this->assertArrayHasKey('data', $output);
$this->assertArrayHasKey('format', $output);
$this->assertIsResource($output['data']);
$this->assertEquals($expectedFormat, $output['format']);
// Cleanup
if (is_resource($output['data'])) {
fclose($output['data']);
}
}
public function testConnectionReturnsNullWhenBodyIsNotResource(): void {
$client = $this->createMock(IClient::class);
$response = $this->createMock(IResponse::class);
$subscription = [
'id' => 42,
'uri' => 'sub123',
'refreshreate' => 'P1H',
'striptodos' => 1,
'stripalarms' => 1,
'stripattachments' => 1,
'source' => 'https://foo.bar/bla2',
'lastmodified' => 0,
];
$this->clientService->expects($this->once())
->method('newClient')
->with()
->willReturn($result);
->willReturn($client);
$this->config->expects($this->once())
->method('getValueString')
->with('dav', 'webcalAllowLocalAccess', 'no')
->willReturn('no');
$client->expects($this->once())
->method('get')
->with('https://foo.bar/bla2')
->willReturn($response);
$response->expects($this->once())
->method('getHeader')
->with('Content-Type')
->willReturn($contentType);
->willReturn('text/calendar');
$this->connection->queryWebcalFeed($subscription);
// Return a string instead of a resource
$response->expects($this->once())
->method('getBody')
->willReturn('not a resource');
$output = $this->connection->queryWebcalFeed($subscription);
$this->assertNull($output);
}
public static function runLocalURLDataProvider(): array {
@ -156,21 +212,9 @@ class ConnectionTest extends TestCase {
public static function urlDataProvider(): array {
return [
[
'https://foo.bar/bla2',
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
'text/calendar;charset=utf8',
],
[
'https://foo.bar/bla2',
'["vcalendar",[["prodid",{},"text","-//Example Corp.//Example Client//EN"],["version",{},"text","2.0"]],[["vtimezone",[["last-modified",{},"date-time","2004-01-10T03:28:45Z"],["tzid",{},"text","US/Eastern"]],[["daylight",[["dtstart",{},"date-time","2000-04-04T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":4}],["tzname",{},"text","EDT"],["tzoffsetfrom",{},"utc-offset","-05:00"],["tzoffsetto",{},"utc-offset","-04:00"]],[]],["standard",[["dtstart",{},"date-time","2000-10-26T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":10}],["tzname",{},"text","EST"],["tzoffsetfrom",{},"utc-offset","-04:00"],["tzoffsetto",{},"utc-offset","-05:00"]],[]]]],["vevent",[["dtstamp",{},"date-time","2006-02-06T00:11:21Z"],["dtstart",{"tzid":"US/Eastern"},"date-time","2006-01-02T14:00:00"],["duration",{},"duration","PT1H"],["recurrence-id",{"tzid":"US/Eastern"},"date-time","2006-01-04T12:00:00"],["summary",{},"text","Event #2"],["uid",{},"text","12345"]],[]]]]',
'application/calendar+json',
],
[
'https://foo.bar/bla2',
'<?xml version="1.0" encoding="utf-8" ?><icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0"><vcalendar><properties><prodid><text>-//Example Inc.//Example Client//EN</text></prodid><version><text>2.0</text></version></properties><components><vevent><properties><dtstamp><date-time>2006-02-06T00:11:21Z</date-time></dtstamp><dtstart><parameters><tzid><text>US/Eastern</text></tzid></parameters><date-time>2006-01-04T14:00:00</date-time></dtstart><duration><duration>PT1H</duration></duration><recurrence-id><parameters><tzid><text>US/Eastern</text></tzid></parameters><date-time>2006-01-04T12:00:00</date-time></recurrence-id><summary><text>Event #2 bis</text></summary><uid><text>12345</text></uid></properties></vevent></components></vcalendar></icalendar>',
'application/calendar+xml',
],
['https://foo.bar/bla2', 'text/calendar;charset=utf8', 'ical'],
['https://foo.bar/bla2', 'application/calendar+json', 'jcal'],
['https://foo.bar/bla2', 'application/calendar+xml', 'xcal'],
];
}
}

@ -8,6 +8,7 @@ declare(strict_types=1);
namespace OCA\DAV\Tests\unit\CalDAV\WebcalCaching;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Import\ImportService;
use OCA\DAV\CalDAV\WebcalCaching\Connection;
use OCA\DAV\CalDAV\WebcalCaching\RefreshWebcalService;
use OCP\AppFramework\Utility\ITimeFactory;
@ -23,7 +24,8 @@ class RefreshWebcalServiceTest extends TestCase {
private CalDavBackend&MockObject $caldavBackend;
private Connection&MockObject $connection;
private LoggerInterface&MockObject $logger;
private ITimeFactory&MockObject $time;
private ImportService&MockObject $importService;
private ITimeFactory&MockObject $timeFactory;
protected function setUp(): void {
parent::setUp();
@ -31,19 +33,32 @@ class RefreshWebcalServiceTest extends TestCase {
$this->caldavBackend = $this->createMock(CalDavBackend::class);
$this->connection = $this->createMock(Connection::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->time = $this->createMock(ITimeFactory::class);
$this->importService = $this->createMock(ImportService::class);
$this->timeFactory = $this->createMock(ITimeFactory::class);
// Default time factory behavior: current time is far in the future so refresh always happens
$this->timeFactory->method('getTime')->willReturn(PHP_INT_MAX);
$this->timeFactory->method('getDateTime')->willReturn(new \DateTime());
}
#[\PHPUnit\Framework\Attributes\DataProvider('runDataProvider')]
public function testRun(string $body, string $contentType, string $result): void {
$refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class)
->onlyMethods(['getRandomCalendarObjectUri'])
->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time])
->getMock();
/**
* Helper to create a resource stream from string content
*/
private function createStreamFromString(string $content) {
$stream = fopen('php://temp', 'r+');
fwrite($stream, $content);
rewind($stream);
return $stream;
}
$refreshWebcalService
->method('getRandomCalendarObjectUri')
->willReturn('uri-1.ics');
#[\PHPUnit\Framework\Attributes\DataProvider('runDataProvider')]
public function testRun(string $body, string $format, string $result): void {
$refreshWebcalService = new RefreshWebcalService(
$this->caldavBackend,
$this->logger,
$this->connection,
$this->timeFactory,
$this->importService
);
$this->caldavBackend->expects(self::once())
->method('getSubscriptionsForUser')
@ -71,26 +86,48 @@ class RefreshWebcalServiceTest extends TestCase {
],
]);
$stream = $this->createStreamFromString($body);
$this->connection->expects(self::once())
->method('queryWebcalFeed')
->willReturn($result);
->willReturn(['data' => $stream, 'format' => $format]);
$this->caldavBackend->expects(self::once())
->method('getLimitedCalendarObjects')
->willReturn([]);
// Create a VCalendar object that will be yielded by the import service
$vCalendar = VObject\Reader::read($result);
$generator = function () use ($vCalendar) {
yield $vCalendar;
};
$this->importService->expects(self::once())
->method('importText')
->willReturn($generator());
$this->caldavBackend->expects(self::once())
->method('createCalendarObject')
->with(42, 'uri-1.ics', $result, 1);
->with(
'42',
self::matchesRegularExpression('/^[a-f0-9-]+\.ics$/'),
$result,
CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION
);
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
}
#[\PHPUnit\Framework\Attributes\DataProvider('identicalDataProvider')]
public function testRunIdentical(string $uid, array $calendarObject, string $body, string $contentType, string $result): void {
$refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class)
->onlyMethods(['getRandomCalendarObjectUri'])
->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time])
->getMock();
$refreshWebcalService
->method('getRandomCalendarObjectUri')
->willReturn('uri-1.ics');
public function testRunIdentical(string $uid, array $calendarObject, string $body, string $format, string $result): void {
$refreshWebcalService = new RefreshWebcalService(
$this->caldavBackend,
$this->logger,
$this->connection,
$this->timeFactory,
$this->importService
);
$this->caldavBackend->expects(self::once())
->method('getSubscriptionsForUser')
@ -118,78 +155,199 @@ class RefreshWebcalServiceTest extends TestCase {
],
]);
$stream = $this->createStreamFromString($body);
$this->connection->expects(self::once())
->method('queryWebcalFeed')
->willReturn($result);
->willReturn(['data' => $stream, 'format' => $format]);
$this->caldavBackend->expects(self::once())
->method('getLimitedCalendarObjects')
->willReturn($calendarObject);
$denormalised = [
'etag' => 100,
'size' => strlen($calendarObject[$uid]['calendardata']),
'uid' => 'sub456'
];
// Create a VCalendar object that will be yielded by the import service
$vCalendar = VObject\Reader::read($result);
$this->caldavBackend->expects(self::once())
->method('getDenormalizedData')
->willReturn($denormalised);
$generator = function () use ($vCalendar) {
yield $vCalendar;
};
$this->importService->expects(self::once())
->method('importText')
->willReturn($generator());
$this->caldavBackend->expects(self::never())
->method('createCalendarObject');
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub456');
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
}
public function testRunJustUpdated(): void {
$refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class)
->onlyMethods(['getRandomCalendarObjectUri'])
->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time])
->getMock();
public function testSubscriptionNotFound(): void {
$refreshWebcalService = new RefreshWebcalService(
$this->caldavBackend,
$this->logger,
$this->connection,
$this->timeFactory,
$this->importService
);
$refreshWebcalService
->method('getRandomCalendarObjectUri')
->willReturn('uri-1.ics');
$this->caldavBackend->expects(self::once())
->method('getSubscriptionsForUser')
->with('principals/users/testuser')
->willReturn([]);
$this->connection->expects(self::never())
->method('queryWebcalFeed');
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
}
public function testConnectionReturnsNull(): void {
$refreshWebcalService = new RefreshWebcalService(
$this->caldavBackend,
$this->logger,
$this->connection,
$this->timeFactory,
$this->importService
);
$this->caldavBackend->expects(self::once())
->method('getSubscriptionsForUser')
->with('principals/users/testuser')
->willReturn([
[
'id' => '99',
'uri' => 'sub456',
RefreshWebcalService::REFRESH_RATE => 'P1D',
'id' => '42',
'uri' => 'sub123',
RefreshWebcalService::STRIP_TODOS => '1',
RefreshWebcalService::STRIP_ALARMS => '1',
RefreshWebcalService::STRIP_ATTACHMENTS => '1',
'source' => 'webcal://foo.bar/bla',
'lastmodified' => time(),
'source' => 'webcal://foo.bar/bla2',
'lastmodified' => 0,
],
]);
$this->connection->expects(self::once())
->method('queryWebcalFeed')
->willReturn(null);
$this->importService->expects(self::never())
->method('importText');
$this->caldavBackend->expects(self::never())
->method('createCalendarObject');
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
}
public function testDeletedObjectsArePurged(): void {
$refreshWebcalService = new RefreshWebcalService(
$this->caldavBackend,
$this->logger,
$this->connection,
$this->timeFactory,
$this->importService
);
$this->caldavBackend->expects(self::once())
->method('getSubscriptionsForUser')
->with('principals/users/testuser')
->willReturn([
[
'id' => '42',
'uri' => 'sub123',
RefreshWebcalService::REFRESH_RATE => 'PT1H',
RefreshWebcalService::STRIP_TODOS => '1',
RefreshWebcalService::STRIP_ALARMS => '1',
RefreshWebcalService::STRIP_ATTACHMENTS => '1',
'source' => 'webcal://foo.bar/bla2',
'lastmodified' => time(),
'lastmodified' => 0,
],
]);
$timeMock = $this->createMock(\DateTime::class);
$this->time->expects(self::once())
->method('getDateTime')
->willReturn($timeMock);
$timeMock->expects(self::once())
->method('getTimestamp')
->willReturn(2101724667);
$this->time->expects(self::once())
->method('getTime')
->willReturn(time());
$this->connection->expects(self::never())
->method('queryWebcalFeed');
$body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:new-event\r\nDTSTAMP:20160218T133704Z\r\nDTSTART:20160218T133704Z\r\nSUMMARY:New Event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
$stream = $this->createStreamFromString($body);
$this->connection->expects(self::once())
->method('queryWebcalFeed')
->willReturn(['data' => $stream, 'format' => 'ical']);
// Existing objects include one that won't be in the feed
$this->caldavBackend->expects(self::once())
->method('getLimitedCalendarObjects')
->willReturn([
'old-deleted-event' => [
'id' => 99,
'uid' => 'old-deleted-event',
'etag' => 'old-etag',
'uri' => 'old-event.ics',
],
]);
$vCalendar = VObject\Reader::read($body);
$generator = function () use ($vCalendar) {
yield $vCalendar;
};
$this->importService->expects(self::once())
->method('importText')
->willReturn($generator());
$this->caldavBackend->expects(self::once())
->method('createCalendarObject');
$this->caldavBackend->expects(self::once())
->method('purgeCachedEventsForSubscription')
->with(42, [99], ['old-event.ics']);
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
}
public function testLongUidIsSkipped(): void {
$refreshWebcalService = new RefreshWebcalService(
$this->caldavBackend,
$this->logger,
$this->connection,
$this->timeFactory,
$this->importService
);
$this->caldavBackend->expects(self::once())
->method('getSubscriptionsForUser')
->with('principals/users/testuser')
->willReturn([
[
'id' => '42',
'uri' => 'sub123',
RefreshWebcalService::STRIP_TODOS => '1',
RefreshWebcalService::STRIP_ALARMS => '1',
RefreshWebcalService::STRIP_ATTACHMENTS => '1',
'source' => 'webcal://foo.bar/bla2',
'lastmodified' => 0,
],
]);
// Create a UID that is longer than 512 characters
$longUid = str_repeat('a', 513);
$body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:$longUid\r\nDTSTAMP:20160218T133704Z\r\nDTSTART:20160218T133704Z\r\nSUMMARY:Event with long UID\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
$stream = $this->createStreamFromString($body);
$this->connection->expects(self::once())
->method('queryWebcalFeed')
->willReturn(['data' => $stream, 'format' => 'ical']);
$this->caldavBackend->expects(self::once())
->method('getLimitedCalendarObjects')
->willReturn([]);
$vCalendar = VObject\Reader::read($body);
$generator = function () use ($vCalendar) {
yield $vCalendar;
};
$this->importService->expects(self::once())
->method('importText')
->willReturn($generator());
// Event with long UID should be skipped, so createCalendarObject should never be called
$this->caldavBackend->expects(self::never())
->method('createCalendarObject');
@ -197,16 +355,12 @@ class RefreshWebcalServiceTest extends TestCase {
}
#[\PHPUnit\Framework\Attributes\DataProvider('runDataProvider')]
public function testRunCreateCalendarNoException(string $body, string $contentType, string $result): void {
public function testRunCreateCalendarNoException(string $body, string $format, string $result): void {
$refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class)
->onlyMethods(['getRandomCalendarObjectUri', 'getSubscription',])
->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time])
->onlyMethods(['getSubscription'])
->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->timeFactory, $this->importService])
->getMock();
$refreshWebcalService
->method('getRandomCalendarObjectUri')
->willReturn('uri-1.ics');
$refreshWebcalService
->method('getSubscription')
->willReturn([
@ -220,13 +374,26 @@ class RefreshWebcalServiceTest extends TestCase {
'lastmodified' => 0,
]);
$stream = $this->createStreamFromString($body);
$this->connection->expects(self::once())
->method('queryWebcalFeed')
->willReturn($result);
->willReturn(['data' => $stream, 'format' => $format]);
$this->caldavBackend->expects(self::once())
->method('createCalendarObject')
->with(42, 'uri-1.ics', $result, 1);
->method('getLimitedCalendarObjects')
->willReturn([]);
// Create a VCalendar object that will be yielded by the import service
$vCalendar = VObject\Reader::read($result);
$generator = function () use ($vCalendar) {
yield $vCalendar;
};
$this->importService->expects(self::once())
->method('importText')
->willReturn($generator());
$noInstanceException = new NoInstancesException("can't add calendar object");
$this->caldavBackend->expects(self::once())
@ -241,16 +408,12 @@ class RefreshWebcalServiceTest extends TestCase {
}
#[\PHPUnit\Framework\Attributes\DataProvider('runDataProvider')]
public function testRunCreateCalendarBadRequest(string $body, string $contentType, string $result): void {
public function testRunCreateCalendarBadRequest(string $body, string $format, string $result): void {
$refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class)
->onlyMethods(['getRandomCalendarObjectUri', 'getSubscription'])
->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time])
->onlyMethods(['getSubscription'])
->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->timeFactory, $this->importService])
->getMock();
$refreshWebcalService
->method('getRandomCalendarObjectUri')
->willReturn('uri-1.ics');
$refreshWebcalService
->method('getSubscription')
->willReturn([
@ -264,13 +427,26 @@ class RefreshWebcalServiceTest extends TestCase {
'lastmodified' => 0,
]);
$stream = $this->createStreamFromString($body);
$this->connection->expects(self::once())
->method('queryWebcalFeed')
->willReturn($result);
->willReturn(['data' => $stream, 'format' => $format]);
$this->caldavBackend->expects(self::once())
->method('createCalendarObject')
->with(42, 'uri-1.ics', $result, 1);
->method('getLimitedCalendarObjects')
->willReturn([]);
// Create a VCalendar object that will be yielded by the import service
$vCalendar = VObject\Reader::read($result);
$generator = function () use ($vCalendar) {
yield $vCalendar;
};
$this->importService->expects(self::once())
->method('importText')
->willReturn($generator());
$badRequestException = new BadRequest("can't add reach calendar url");
$this->caldavBackend->expects(self::once())
@ -285,20 +461,22 @@ class RefreshWebcalServiceTest extends TestCase {
}
public static function identicalDataProvider(): array {
$icalBody = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
$etag = md5($icalBody);
return [
[
'12345',
[
'12345' => [
'id' => 42,
'etag' => 100,
'uri' => 'sub456',
'calendardata' => "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
'etag' => $etag,
'uri' => 'sub456.ics',
],
],
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
'text/calendar;charset=utf8',
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20180218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
'ical',
$icalBody,
],
];
}
@ -307,19 +485,9 @@ class RefreshWebcalServiceTest extends TestCase {
return [
[
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
'text/calendar;charset=utf8',
'ical',
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
],
[
'["vcalendar",[["prodid",{},"text","-//Example Corp.//Example Client//EN"],["version",{},"text","2.0"]],[["vtimezone",[["last-modified",{},"date-time","2004-01-10T03:28:45Z"],["tzid",{},"text","US/Eastern"]],[["daylight",[["dtstart",{},"date-time","2000-04-04T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":4}],["tzname",{},"text","EDT"],["tzoffsetfrom",{},"utc-offset","-05:00"],["tzoffsetto",{},"utc-offset","-04:00"]],[]],["standard",[["dtstart",{},"date-time","2000-10-26T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":10}],["tzname",{},"text","EST"],["tzoffsetfrom",{},"utc-offset","-04:00"],["tzoffsetto",{},"utc-offset","-05:00"]],[]]]],["vevent",[["dtstamp",{},"date-time","2006-02-06T00:11:21Z"],["dtstart",{"tzid":"US/Eastern"},"date-time","2006-01-02T14:00:00"],["duration",{},"duration","PT1H"],["recurrence-id",{"tzid":"US/Eastern"},"date-time","2006-01-04T12:00:00"],["summary",{},"text","Event #2"],["uid",{},"text","12345"]],[]]]]',
'application/calendar+json',
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTIMEZONE\r\nLAST-MODIFIED:20040110T032845Z\r\nTZID:US/Eastern\r\nBEGIN:DAYLIGHT\r\nDTSTART:20000404T020000\r\nRRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4\r\nTZNAME:EDT\r\nTZOFFSETFROM:-0500\r\nTZOFFSETTO:-0400\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nDTSTART:20001026T020000\r\nRRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=10\r\nTZNAME:EST\r\nTZOFFSETFROM:-0400\r\nTZOFFSETTO:-0500\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nBEGIN:VEVENT\r\nDTSTAMP:20060206T001121Z\r\nDTSTART;TZID=US/Eastern:20060102T140000\r\nDURATION:PT1H\r\nRECURRENCE-ID;TZID=US/Eastern:20060104T120000\r\nSUMMARY:Event #2\r\nUID:12345\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
],
[
'<?xml version="1.0" encoding="utf-8" ?><icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0"><vcalendar><properties><prodid><text>-//Example Inc.//Example Client//EN</text></prodid><version><text>2.0</text></version></properties><components><vevent><properties><dtstamp><date-time>2006-02-06T00:11:21Z</date-time></dtstamp><dtstart><parameters><tzid><text>US/Eastern</text></tzid></parameters><date-time>2006-01-04T14:00:00</date-time></dtstart><duration><duration>PT1H</duration></duration><recurrence-id><parameters><tzid><text>US/Eastern</text></tzid></parameters><date-time>2006-01-04T12:00:00</date-time></recurrence-id><summary><text>Event #2 bis</text></summary><uid><text>12345</text></uid></properties></vevent></components></vcalendar></icalendar>',
'application/calendar+xml',
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nDTSTAMP:20060206T001121Z\r\nDTSTART;TZID=US/Eastern:20060104T140000\r\nDURATION:PT1H\r\nRECURRENCE-ID;TZID=US/Eastern:20060104T120000\r\nSUMMARY:Event #2 bis\r\nUID:12345\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
]
];
}
}

@ -433,11 +433,6 @@
<code><![CDATA[!isset($newProps['filters']['props']) || !is_array($newProps['filters']['props'])]]></code>
</TypeDoesNotContainType>
</file>
<file src="apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php">
<InvalidArgument>
<code><![CDATA[$webcalData]]></code>
</InvalidArgument>
</file>
<file src="apps/dav/lib/CardDAV/AddressBookImpl.php">
<InvalidArgument>
<code><![CDATA[$this->getKey()]]></code>