diff options
author | Christoph Wurst <christoph@winzerhof-wurst.at> | 2021-03-12 13:20:04 +0300 |
---|---|---|
committer | Christoph Wurst <christoph@winzerhof-wurst.at> | 2021-05-31 08:49:19 +0300 |
commit | d6d8e9215c69a9e8d4bfa2035c657b0a037f3a2f (patch) | |
tree | 942254503672d6ab3e0b6c8fca0bd4053db352b9 /apps/dav/lib/CalDAV | |
parent | 9e596dd0cf455dc1639141c695f89c7d936f4c0c (diff) |
Add a trashbin for calendars and calendar objects
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
Diffstat (limited to 'apps/dav/lib/CalDAV')
-rw-r--r-- | apps/dav/lib/CalDAV/Activity/Backend.php | 22 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/Activity/Provider/Calendar.php | 14 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/Activity/Provider/Event.php | 14 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/CalDavBackend.php | 430 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/Calendar.php | 20 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/CalendarHome.php | 20 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/CalendarObject.php | 4 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/IRestorable.php | 41 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/RetentionService.php | 80 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php | 96 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php | 131 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/Trashbin/Plugin.php | 96 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php | 82 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php | 129 |
14 files changed, 1120 insertions, 59 deletions
diff --git a/apps/dav/lib/CalDAV/Activity/Backend.php b/apps/dav/lib/CalDAV/Activity/Backend.php index 16f581db872..4b896de3b89 100644 --- a/apps/dav/lib/CalDAV/Activity/Backend.php +++ b/apps/dav/lib/CalDAV/Activity/Backend.php @@ -85,12 +85,32 @@ class Backend { } /** + * Creates activities when a calendar was moved to trash + * + * @param array $calendarData + * @param array $shares + */ + public function onCalendarMovedToTrash(array $calendarData, array $shares): void { + $this->triggerCalendarActivity(Calendar::SUBJECT_MOVE_TO_TRASH, $calendarData, $shares); + } + + /** + * Creates activities when a calendar was restored + * + * @param array $calendarData + * @param array $shares + */ + public function onCalendarRestored(array $calendarData, array $shares): void { + $this->triggerCalendarActivity(Calendar::SUBJECT_RESTORE, $calendarData, $shares); + } + + /** * Creates activities when a calendar was deleted * * @param array $calendarData * @param array $shares */ - public function onCalendarDelete(array $calendarData, array $shares) { + public function onCalendarDelete(array $calendarData, array $shares): void { $this->triggerCalendarActivity(Calendar::SUBJECT_DELETE, $calendarData, $shares); } diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php b/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php index 382825d8955..a30c628a3ad 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php @@ -39,6 +39,8 @@ use OCP\L10N\IFactory; class Calendar extends Base { public const SUBJECT_ADD = 'calendar_add'; public const SUBJECT_UPDATE = 'calendar_update'; + public const SUBJECT_MOVE_TO_TRASH = 'calendar_move_to_trash'; + public const SUBJECT_RESTORE = 'calendar_restore'; public const SUBJECT_DELETE = 'calendar_delete'; public const SUBJECT_PUBLISH = 'calendar_publish'; public const SUBJECT_UNPUBLISH = 'calendar_unpublish'; @@ -107,6 +109,14 @@ class Calendar extends Base { $subject = $this->l->t('{actor} updated calendar {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_UPDATE . '_self') { $subject = $this->l->t('You updated calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_MOVE_TO_TRASH) { + $subject = $this->l->t('{actor} deleted calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_MOVE_TO_TRASH . '_self') { + $subject = $this->l->t('You deleted calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_RESTORE) { + $subject = $this->l->t('{actor} restored calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_RESTORE . '_self') { + $subject = $this->l->t('You restored calendar {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_PUBLISH . '_self') { $subject = $this->l->t('You shared calendar {calendar} as public link'); } elseif ($event->getSubject() === self::SUBJECT_UNPUBLISH . '_self') { @@ -172,6 +182,10 @@ class Calendar extends Base { case self::SUBJECT_DELETE . '_self': case self::SUBJECT_UPDATE: case self::SUBJECT_UPDATE . '_self': + case self::SUBJECT_MOVE_TO_TRASH: + case self::SUBJECT_MOVE_TO_TRASH . '_self': + case self::SUBJECT_RESTORE: + case self::SUBJECT_RESTORE . '_self': case self::SUBJECT_PUBLISH . '_self': case self::SUBJECT_UNPUBLISH . '_self': case self::SUBJECT_SHARE_USER: diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Event.php b/apps/dav/lib/CalDAV/Activity/Provider/Event.php index 8850715a1c5..03845cc4d87 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Event.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Event.php @@ -40,6 +40,8 @@ use OCP\L10N\IFactory; class Event extends Base { public const SUBJECT_OBJECT_ADD = 'object_add'; public const SUBJECT_OBJECT_UPDATE = 'object_update'; + public const SUBJECT_OBJECT_MOVE_TO_TRASH = 'object_move_to_trash'; + public const SUBJECT_OBJECT_RESTORE = 'object_restore'; public const SUBJECT_OBJECT_DELETE = 'object_delete'; /** @var IFactory */ @@ -143,6 +145,14 @@ class Event extends Base { $subject = $this->l->t('{actor} updated event {event} in calendar {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_event_self') { $subject = $this->l->t('You updated event {event} in calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE_TO_TRASH . '_event') { + $subject = $this->l->t('{actor} deleted event {event} from calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE_TO_TRASH . '_event_self') { + $subject = $this->l->t('You deleted event {event} from calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_RESTORE . '_event') { + $subject = $this->l->t('{actor} restored event {event} of calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_RESTORE . '_event_self') { + $subject = $this->l->t('You restored event {event} of calendar {calendar}'); } else { throw new \InvalidArgumentException(); } @@ -169,6 +179,8 @@ class Event extends Base { case self::SUBJECT_OBJECT_ADD . '_event': case self::SUBJECT_OBJECT_DELETE . '_event': case self::SUBJECT_OBJECT_UPDATE . '_event': + case self::SUBJECT_OBJECT_MOVE_TO_TRASH . '_event': + case self::SUBJECT_OBJECT_RESTORE . '_event': return [ 'actor' => $this->generateUserParameter($parameters['actor']), 'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l), @@ -177,6 +189,8 @@ class Event extends Base { case self::SUBJECT_OBJECT_ADD . '_event_self': case self::SUBJECT_OBJECT_DELETE . '_event_self': case self::SUBJECT_OBJECT_UPDATE . '_event_self': + case self::SUBJECT_OBJECT_MOVE_TO_TRASH . '_event_self': + case self::SUBJECT_OBJECT_RESTORE . '_event_self': return [ 'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l), 'event' => $this->generateClassifiedObjectParameter($parameters['object']), diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 2daa03843de..53749855b8c 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -39,6 +39,7 @@ namespace OCA\DAV\CalDAV; use DateTime; +use OCA\DAV\AppInfo\Application; use OCA\DAV\Connector\Sabre\Principal; use OCA\DAV\DAV\Sharing\Backend; use OCA\DAV\DAV\Sharing\IShareable; @@ -47,10 +48,14 @@ use OCA\DAV\Events\CachedCalendarObjectDeletedEvent; use OCA\DAV\Events\CachedCalendarObjectUpdatedEvent; use OCA\DAV\Events\CalendarCreatedEvent; use OCA\DAV\Events\CalendarDeletedEvent; +use OCA\DAV\Events\CalendarMovedToTrashEvent; use OCA\DAV\Events\CalendarObjectCreatedEvent; use OCA\DAV\Events\CalendarObjectDeletedEvent; +use OCA\DAV\Events\CalendarObjectMovedToTrashEvent; +use OCA\DAV\Events\CalendarObjectRestoredEvent; use OCA\DAV\Events\CalendarObjectUpdatedEvent; use OCA\DAV\Events\CalendarPublishedEvent; +use OCA\DAV\Events\CalendarRestoredEvent; use OCA\DAV\Events\CalendarShareUpdatedEvent; use OCA\DAV\Events\CalendarUnpublishedEvent; use OCA\DAV\Events\CalendarUpdatedEvent; @@ -59,12 +64,14 @@ use OCA\DAV\Events\SubscriptionDeletedEvent; use OCA\DAV\Events\SubscriptionUpdatedEvent; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IConfig; use OCP\IDBConnection; use OCP\IGroupManager; use OCP\ILogger; use OCP\IUser; use OCP\IUserManager; use OCP\Security\ISecureRandom; +use RuntimeException; use Sabre\CalDAV\Backend\AbstractBackend; use Sabre\CalDAV\Backend\SchedulingSupport; use Sabre\CalDAV\Backend\SubscriptionSupport; @@ -72,6 +79,7 @@ use Sabre\CalDAV\Backend\SyncSupport; use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp; use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet; use Sabre\DAV; +use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\PropPatch; @@ -87,6 +95,15 @@ use Sabre\VObject\Reader; use Sabre\VObject\Recur\EventIterator; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\GenericEvent; +use function array_merge; +use function array_values; +use function explode; +use function is_array; +use function pathinfo; +use function sprintf; +use function str_replace; +use function strtolower; +use function time; /** * Class CalDavBackend @@ -134,6 +151,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription '{urn:ietf:params:xml:ns:caldav}calendar-timezone' => 'timezone', '{http://apple.com/ns/ical/}calendar-order' => 'calendarorder', '{http://apple.com/ns/ical/}calendar-color' => 'calendarcolor', + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => 'deleted_at', ]; /** @@ -191,6 +209,9 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription /** @var EventDispatcherInterface */ private $legacyDispatcher; + /** @var IConfig */ + private $config; + /** @var bool */ private $legacyEndpoint; @@ -218,6 +239,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ILogger $logger, IEventDispatcher $dispatcher, EventDispatcherInterface $legacyDispatcher, + IConfig $config, bool $legacyEndpoint = false) { $this->db = $db; $this->principalBackend = $principalBackend; @@ -227,6 +249,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $this->logger = $logger; $this->dispatcher = $dispatcher; $this->legacyDispatcher = $legacyDispatcher; + $this->config = $config; $this->legacyEndpoint = $legacyEndpoint; } @@ -262,6 +285,26 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** + * @return array{id: int, deleted_at: int}[] + */ + public function getDeletedCalendars(int $deletedBefore): array { + $qb = $this->db->getQueryBuilder(); + $qb->select(['id', 'deleted_at']) + ->from('calendars') + ->where($qb->expr()->isNotNull('deleted_at')) + ->andWhere($qb->expr()->lt('deleted_at', $qb->createNamedParameter($deletedBefore))); + $result = $qb->executeQuery(); + $raw = $result->fetchAll(); + $result->closeCursor(); + return array_map(function ($row) { + return [ + 'id' => (int) $row['id'], + 'deleted_at' => (int) $row['deleted_at'], + ]; + }, $raw); + } + + /** * Returns a list of calendars for a principal. * * Every project is an array with the following keys: @@ -334,7 +377,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $calendar[$xmlName] = $row[$dbName]; } - $this->addOwnerPrincipal($calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); if (!isset($calendars[$calendar['id']])) { $calendars[$calendar['id']] = $calendar; @@ -410,7 +454,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $calendar[$xmlName] = $row[$dbName]; } - $this->addOwnerPrincipal($calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); $calendars[$calendar['id']] = $calendar; } @@ -458,7 +503,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $calendar[$xmlName] = $row[$dbName]; } - $this->addOwnerPrincipal($calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); if (!isset($calendars[$calendar['id']])) { $calendars[$calendar['id']] = $calendar; @@ -534,7 +580,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $calendar[$xmlName] = $row[$dbName]; } - $this->addOwnerPrincipal($calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); if (!isset($calendars[$calendar['id']])) { $calendars[$calendar['id']] = $calendar; @@ -601,7 +648,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $calendar[$xmlName] = $row[$dbName]; } - $this->addOwnerPrincipal($calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); return $calendar; } @@ -654,7 +702,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $calendar[$xmlName] = $row[$dbName]; } - $this->addOwnerPrincipal($calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); return $calendar; } @@ -705,7 +754,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $calendar[$xmlName] = $row[$dbName]; } - $this->addOwnerPrincipal($calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); return $calendar; } @@ -872,33 +922,84 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param mixed $calendarId * @return void */ - public function deleteCalendar($calendarId) { + public function deleteCalendar($calendarId, bool $forceDeletePermanently = false) { + // The calendar is deleted right away if this is either enforced by the caller + // or the special contacts birthday calendar or when the preference of an empty + // retention (0 seconds) is set, which signals a disabled trashbin. $calendarData = $this->getCalendarById($calendarId); - $shares = $this->getShares($calendarId); + $isBirthdayCalendar = isset($calendarData['uri']) && $calendarData['uri'] === BirthdayService::BIRTHDAY_CALENDAR_URI; + $trashbinDisabled = $this->config->getAppValue(Application::APP_ID, RetentionService::RETENTION_CONFIG_KEY) === '0'; + if ($forceDeletePermanently || $isBirthdayCalendar || $trashbinDisabled) { + $calendarData = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); - $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ? AND `calendartype` = ?'); - $stmt->execute([$calendarId, self::CALENDAR_TYPE_CALENDAR]); + $qbDeleteCalendarObjectProps = $this->db->getQueryBuilder(); + $qbDeleteCalendarObjectProps->delete($this->dbObjectPropertiesTable) + ->where($qbDeleteCalendarObjectProps->expr()->eq('calendarid', $qbDeleteCalendarObjectProps->createNamedParameter($calendarId))) + ->andWhere($qbDeleteCalendarObjectProps->expr()->eq('calendartype', $qbDeleteCalendarObjectProps->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) + ->executeStatement(); - $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendars` WHERE `id` = ?'); - $stmt->execute([$calendarId]); + $qbDeleteCalendarObjects = $this->db->getQueryBuilder(); + $qbDeleteCalendarObjects->delete('calendarobjects') + ->where($qbDeleteCalendarObjects->expr()->eq('calendarid', $qbDeleteCalendarObjects->createNamedParameter($calendarId))) + ->andWhere($qbDeleteCalendarObjects->expr()->eq('calendartype', $qbDeleteCalendarObjects->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) + ->executeStatement(); - $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarchanges` WHERE `calendarid` = ? AND `calendartype` = ?'); - $stmt->execute([$calendarId, self::CALENDAR_TYPE_CALENDAR]); + $qbDeleteCalendarChanges = $this->db->getQueryBuilder(); + $qbDeleteCalendarObjects->delete('calendarchanges') + ->where($qbDeleteCalendarChanges->expr()->eq('calendarid', $qbDeleteCalendarChanges->createNamedParameter($calendarId))) + ->andWhere($qbDeleteCalendarChanges->expr()->eq('calendartype', $qbDeleteCalendarChanges->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) + ->executeStatement(); - $this->calendarSharingBackend->deleteAllShares($calendarId); + $this->calendarSharingBackend->deleteAllShares($calendarId); - $query = $this->db->getQueryBuilder(); - $query->delete($this->dbObjectPropertiesTable) - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) - ->executeStatement(); + $qbDeleteCalendar = $this->db->getQueryBuilder(); + $qbDeleteCalendarObjects->delete('calendars') + ->where($qbDeleteCalendar->expr()->eq('id', $qbDeleteCalendar->createNamedParameter($calendarId))) + ->executeStatement(); + + // Only dispatch if we actually deleted anything + if ($calendarData) { + $this->dispatcher->dispatchTyped(new CalendarDeletedEvent((int)$calendarId, $calendarData, $shares)); + } + } else { + $qbMarkCalendarDeleted = $this->db->getQueryBuilder(); + $qbMarkCalendarDeleted->update('calendars') + ->set('deleted_at', $qbMarkCalendarDeleted->createNamedParameter(time())) + ->where($qbMarkCalendarDeleted->expr()->eq('id', $qbMarkCalendarDeleted->createNamedParameter($calendarId))) + ->executeStatement(); - // Only dispatch if we actually deleted anything - if ($calendarData) { - $this->dispatcher->dispatchTyped(new CalendarDeletedEvent((int)$calendarId, $calendarData, $shares)); + $calendarData = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); + if ($calendarData) { + $this->dispatcher->dispatchTyped(new CalendarMovedToTrashEvent( + (int)$calendarId, + $calendarData, + $shares + )); + } } } + public function restoreCalendar(int $id): void { + $qb = $this->db->getQueryBuilder(); + $update = $qb->update('calendars') + ->set('deleted_at', $qb->createNamedParameter(null)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + $update->executeStatement(); + + $calendarData = $this->getCalendarById($id); + $shares = $this->getShares($id); + if ($calendarData === null) { + throw new RuntimeException('Calendar data that was just written can\'t be read back. Check your database configuration.'); + } + $this->dispatcher->dispatchTyped(new CalendarRestoredEvent( + $id, + $calendarData, + $shares + )); + } + /** * Delete all of an user's shares * @@ -946,7 +1047,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $query->select(['id', 'uri', 'lastmodified', 'etag', 'calendarid', 'size', 'componenttype', 'classification']) ->from('calendarobjects') ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))); + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) + ->andWhere($query->expr()->isNull('deleted_at')); $stmt = $query->executeQuery(); $result = []; @@ -967,6 +1069,63 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return $result; } + public function getDeletedCalendarObjects(int $deletedBefore): array { + $query = $this->db->getQueryBuilder(); + $query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.calendartype', 'co.size', 'co.componenttype', 'co.classification', 'co.deleted_at']) + ->from('calendarobjects', 'co') + ->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT)) + ->where($query->expr()->isNotNull('co.deleted_at')) + ->andWhere($query->expr()->lt('co.deleted_at', $query->createNamedParameter($deletedBefore))); + $stmt = $query->executeQuery(); + + $result = []; + foreach ($stmt->fetchAll() as $row) { + $result[] = [ + 'id' => $row['id'], + 'uri' => $row['uri'], + 'lastmodified' => $row['lastmodified'], + 'etag' => '"' . $row['etag'] . '"', + 'calendarid' => (int) $row['calendarid'], + 'calendartype' => (int) $row['calendartype'], + 'size' => (int) $row['size'], + 'component' => strtolower($row['componenttype']), + 'classification' => (int) $row['classification'], + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'], + ]; + } + $stmt->closeCursor(); + + return $result; + } + + public function getDeletedCalendarObjectsByPrincipal(string $principalUri): array { + $query = $this->db->getQueryBuilder(); + $query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.componenttype', 'co.classification', 'co.deleted_at']) + ->from('calendarobjects', 'co') + ->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) + ->andWhere($query->expr()->isNotNull('co.deleted_at')); + $stmt = $query->executeQuery(); + + $result = []; + while ($row = $stmt->fetch()) { + $result[] = [ + 'id' => $row['id'], + 'uri' => $row['uri'], + 'lastmodified' => $row['lastmodified'], + 'etag' => '"' . $row['etag'] . '"', + 'calendarid' => $row['calendarid'], + 'size' => (int)$row['size'], + 'component' => strtolower($row['componenttype']), + 'classification' => (int)$row['classification'], + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'], + ]; + } + $stmt->closeCursor(); + + return $result; + } + /** * Returns information from a single calendar object, based on it's object * uri. @@ -984,7 +1143,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param int $calendarType * @return array|null */ - public function getCalendarObject($calendarId, $objectUri, $calendarType = self::CALENDAR_TYPE_CALENDAR) { + public function getCalendarObject($calendarId, $objectUri, int $calendarType = self::CALENDAR_TYPE_CALENDAR) { $query = $this->db->getQueryBuilder(); $query->select(['id', 'uri', 'lastmodified', 'etag', 'calendarid', 'size', 'calendardata', 'componenttype', 'classification']) ->from('calendarobjects') @@ -1038,7 +1197,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->from('calendarobjects') ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) ->andWhere($query->expr()->in('uri', $query->createParameter('uri'))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))); + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) + ->andWhere($query->expr()->isNull('deleted_at')); foreach ($chunks as $uris) { $query->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY); @@ -1085,19 +1245,34 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription public function createCalendarObject($calendarId, $objectUri, $calendarData, $calendarType = self::CALENDAR_TYPE_CALENDAR) { $extraData = $this->getDenormalizedData($calendarData); - $q = $this->db->getQueryBuilder(); - $q->select($q->func()->count('*')) + // Try to detect duplicates + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*')) ->from('calendarobjects') - ->where($q->expr()->eq('calendarid', $q->createNamedParameter($calendarId))) - ->andWhere($q->expr()->eq('uid', $q->createNamedParameter($extraData['uid']))) - ->andWhere($q->expr()->eq('calendartype', $q->createNamedParameter($calendarType))); - - $result = $q->executeQuery(); + ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) + ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($extraData['uid']))) + ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) + ->andWhere($qb->expr()->isNull('deleted_at')); + $result = $qb->executeQuery(); $count = (int) $result->fetchOne(); $result->closeCursor(); if ($count !== 0) { - throw new \Sabre\DAV\Exception\BadRequest('Calendar object with uid already exists in this calendar collection.'); + throw new BadRequest('Calendar object with uid already exists in this calendar collection.'); + } + // For a more specific error message we also try to explicitly look up the UID but as a deleted entry + $qbDel = $this->db->getQueryBuilder(); + $qbDel->select($qb->func()->count('*')) + ->from('calendarobjects') + ->where($qbDel->expr()->eq('calendarid', $qbDel->createNamedParameter($calendarId))) + ->andWhere($qbDel->expr()->eq('uid', $qbDel->createNamedParameter($extraData['uid']))) + ->andWhere($qbDel->expr()->eq('calendartype', $qbDel->createNamedParameter($calendarType))) + ->andWhere($qbDel->expr()->isNotNull('deleted_at')); + $result = $qbDel->executeQuery(); + $count = (int) $result->fetchOne(); + $result->closeCursor(); + if ($count !== 0) { + throw new BadRequest('Deleted calendar object with uid already exists in this calendar collection.'); } $query = $this->db->getQueryBuilder(); @@ -1236,11 +1411,23 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param mixed $calendarId * @param string $objectUri * @param int $calendarType + * @param bool $forceDeletePermanently * @return void */ - public function deleteCalendarObject($calendarId, $objectUri, $calendarType = self::CALENDAR_TYPE_CALENDAR) { + public function deleteCalendarObject($calendarId, $objectUri, $calendarType = self::CALENDAR_TYPE_CALENDAR, bool $forceDeletePermanently = false) { $data = $this->getCalendarObject($calendarId, $objectUri, $calendarType); - if (is_array($data)) { + + if ($data === null) { + // Nothing to delete + return; + } + + if ($forceDeletePermanently || $this->config->getAppValue(Application::APP_ID, RetentionService::RETENTION_CONFIG_KEY) === '0') { + $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ? AND `uri` = ? AND `calendartype` = ?'); + $stmt->execute([$calendarId, $objectUri, $calendarType]); + + $this->purgeProperties($calendarId, $data['id']); + if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { $calendarRow = $this->getCalendarById($calendarId); $shares = $this->getShares($calendarId); @@ -1260,19 +1447,108 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ] )); } - } + } else { + $pathInfo = pathinfo($data['uri']); + if (!empty($pathInfo['extension'])) { + // Append a suffix to "free" the old URI for recreation + $newUri = sprintf( + "%s-deleted.%s", + $pathInfo['filename'], + $pathInfo['extension'] + ); + } else { + $newUri = sprintf( + "%s-deleted", + $pathInfo['filename'] + ); + } - $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ? AND `uri` = ? AND `calendartype` = ?'); - $stmt->execute([$calendarId, $objectUri, $calendarType]); + // Try to detect conflicts before the DB does + // As unlikely as it seems, this can happen when the user imports, then deletes, imports and deletes again + $newObject = $this->getCalendarObject($calendarId, $newUri, $calendarType); + if ($newObject !== null) { + throw new Forbidden("A calendar object with URI $newUri already exists in calendar $calendarId, therefore this object can't be moved into the trashbin"); + } + + $qb = $this->db->getQueryBuilder(); + $markObjectDeletedQuery = $qb->update('calendarobjects') + ->set('deleted_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT)) + ->set('uri', $qb->createNamedParameter($newUri)) + ->where( + $qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)), + $qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), + $qb->expr()->eq('uri', $qb->createNamedParameter($objectUri)) + ); + $markObjectDeletedQuery->executeStatement(); - if (is_array($data)) { - $this->purgeProperties($calendarId, $data['id'], $calendarType); + $calendarData = $this->getCalendarById($calendarId); + if ($calendarData !== null) { + $this->dispatcher->dispatchTyped( + new CalendarObjectMovedToTrashEvent( + (int)$calendarId, + $calendarData, + $this->getShares($calendarId), + $data + ) + ); + } } $this->addChange($calendarId, $objectUri, 3, $calendarType); } /** + * @param mixed $objectData + * + * @throws Forbidden + */ + public function restoreCalendarObject(array $objectData): void { + $id = (int) $objectData['id']; + $restoreUri = str_replace("-deleted.ics", ".ics", $objectData['uri']); + $targetObject = $this->getCalendarObject( + $objectData['calendarid'], + $restoreUri + ); + if ($targetObject !== null) { + throw new Forbidden("Can not restore calendar $id because a calendar object with the URI $restoreUri already exists"); + } + + $qb = $this->db->getQueryBuilder(); + $update = $qb->update('calendarobjects') + ->set('uri', $qb->createNamedParameter($restoreUri)) + ->set('deleted_at', $qb->createNamedParameter(null)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + $update->executeStatement(); + + // Make sure this change is tracked in the changes table + $qb2 = $this->db->getQueryBuilder(); + $selectObject = $qb2->select('calendardata', 'uri', 'calendarid', 'calendartype') + ->from('calendarobjects') + ->where($qb2->expr()->eq('id', $qb2->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + $result = $selectObject->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + if ($row === false) { + // Welp, this should possibly not have happened, but let's ignore + return; + } + $this->addChange($row['calendarid'], $row['uri'], 1, (int) $row['calendartype']); + + $calendarRow = $this->getCalendarById((int) $row['calendarid']); + if ($calendarRow === null) { + throw new RuntimeException('Calendar object data that was just written can\'t be read back. Check your database configuration.'); + } + $this->dispatcher->dispatchTyped( + new CalendarObjectRestoredEvent( + (int) $objectData['calendarid'], + $calendarRow, + $this->getShares((int) $row['calendarid']), + $row + ) + ); + } + + /** * Performs a calendar-query on the contents of this calendar. * * The calendar-query is defined in RFC4791 : CalDAV. Using the @@ -1359,7 +1635,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $query->select($columns) ->from('calendarobjects') ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))); + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) + ->andWhere($query->expr()->isNull('deleted_at')); if ($componentType) { $query->andWhere($query->expr()->eq('componenttype', $query->createNamedParameter($componentType))); @@ -1508,7 +1785,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->andWhere($compExpr) ->andWhere($propParamExpr) ->andWhere($query->expr()->iLike('i.value', - $query->createNamedParameter('%'.$this->db->escapeLikeParameter($filters['search-term']).'%'))); + $query->createNamedParameter('%'.$this->db->escapeLikeParameter($filters['search-term']).'%'))) + ->andWhere($query->expr()->isNull('deleted_at')); if ($offset) { $query->setFirstResult($offset); @@ -1574,7 +1852,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } $outerQuery->select('c.id', 'c.calendardata', 'c.componenttype', 'c.uid', 'c.uri') - ->from('calendarobjects', 'c'); + ->from('calendarobjects', 'c') + ->where($outerQuery->expr()->isNull('deleted_at')); if (isset($options['timerange'])) { if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTime) { @@ -1776,7 +2055,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->leftJoin('cob', 'calendarobjects', 'co', $calendarObjectIdQuery->expr()->eq('co.id', 'cob.objectid')) ->andWhere($calendarObjectIdQuery->expr()->in('co.componenttype', $calendarObjectIdQuery->createNamedParameter($componentTypes, IQueryBuilder::PARAM_STR_ARRAY))) ->andWhere($calendarOr) - ->andWhere($searchOr); + ->andWhere($searchOr) + ->andWhere($calendarObjectIdQuery->expr()->isNull('deleted_at')); if ('' !== $pattern) { if (!$escapePattern) { @@ -1843,8 +2123,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->from('calendarobjects', 'co') ->leftJoin('co', 'calendars', 'c', $query->expr()->eq('co.calendarid', 'c.id')) ->where($query->expr()->eq('c.principaluri', $query->createNamedParameter($principalUri))) - ->andWhere($query->expr()->eq('co.uid', $query->createNamedParameter($uid))); - + ->andWhere($query->expr()->eq('co.uid', $query->createNamedParameter($uid))) + ->andWhere($query->expr()->isNull('co.deleted_at')); $stmt = $query->executeQuery(); $row = $stmt->fetch(); $stmt->closeCursor(); @@ -1855,6 +2135,35 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return null; } + public function getCalendarObjectById(string $principalUri, int $id): ?array { + $query = $this->db->getQueryBuilder(); + $query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.calendardata', 'co.componenttype', 'co.classification', 'co.deleted_at']) + ->from('calendarobjects', 'co') + ->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('c.principaluri', $query->createNamedParameter($principalUri))) + ->andWhere($query->expr()->eq('co.id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + $stmt = $query->executeQuery(); + $row = $stmt->fetch(); + $stmt->closeCursor(); + + if (!$row) { + return null; + } + + return [ + 'id' => $row['id'], + 'uri' => $row['uri'], + 'lastmodified' => $row['lastmodified'], + 'etag' => '"' . $row['etag'] . '"', + 'calendarid' => $row['calendarid'], + 'size' => (int)$row['size'], + 'calendardata' => $this->readBlob($row['calendardata']), + 'component' => strtolower($row['componenttype']), + 'classification' => (int)$row['classification'], + 'deleted_at' => isset($row['deleted_at']) ? ((int) $row['deleted_at']) : null, + ]; + } + /** * The getChanges method returns all the changes that have happened, since * the specified syncToken in the specified calendar. @@ -2410,7 +2719,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } } if (!$componentType) { - throw new \Sabre\DAV\Exception\BadRequest('Calendar objects must have a VJOURNAL, VEVENT or VTODO component'); + throw new BadRequest('Calendar objects must have a VJOURNAL, VEVENT or VTODO component'); } if ($hasDTSTART) { @@ -2670,7 +2979,10 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $ids = $result->fetchAll(); foreach ($ids as $id) { - $this->deleteCalendar($id['id']); + $this->deleteCalendar( + $id['id'], + true // No data to keep in the trashbin, if the user re-enables then we regenerate + ); } } @@ -2802,9 +3114,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription /** * adds information about an owner to the calendar data * - * @param $calendarInfo */ - private function addOwnerPrincipal(&$calendarInfo) { + private function addOwnerPrincipalToCalendar(array $calendarInfo): array { $ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'; $displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname'; if (isset($calendarInfo[$ownerPrincipalKey])) { @@ -2817,5 +3128,20 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription if (isset($principalInformation['{DAV:}displayname'])) { $calendarInfo[$displaynameKey] = $principalInformation['{DAV:}displayname']; } + return $calendarInfo; + } + + private function addResourceTypeToCalendar(array $row, array $calendar): array { + if (isset($row['deleted_at'])) { + // Columns is set and not null -> this is a deleted calendar + // we send a custom resourcetype to hide the deleted calendar + // from ordinary DAV clients, but the Calendar app will know + // how to handle this special resource. + $calendar['{DAV:}resourcetype'] = new DAV\Xml\Property\ResourceType([ + '{DAV:}collection', + sprintf('{%s}deleted-calendar', \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD), + ]); + } + return $calendar; } } diff --git a/apps/dav/lib/CalDAV/Calendar.php b/apps/dav/lib/CalDAV/Calendar.php index abddf2b706f..191ac384e72 100644 --- a/apps/dav/lib/CalDAV/Calendar.php +++ b/apps/dav/lib/CalDAV/Calendar.php @@ -42,9 +42,9 @@ use Sabre\DAV\PropPatch; * Class Calendar * * @package OCA\DAV\CalDAV - * @property BackendInterface|CalDavBackend $caldavBackend + * @property CalDavBackend $caldavBackend */ -class Calendar extends \Sabre\CalDAV\Calendar implements IShareable { +class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable { /** @var IConfig */ private $config; @@ -52,6 +52,9 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable { /** @var IL10N */ protected $l10n; + /** @var bool */ + private $useTrashbin = true; + /** * Calendar constructor. * @@ -269,7 +272,10 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable { $this->config->setUserValue($userId, 'dav', 'generateBirthdayCalendar', 'no'); } - parent::delete(); + $this->caldavBackend->deleteCalendar( + $this->calendarInfo['id'], + !$this->useTrashbin + ); } public function propPatch(PropPatch $propPatch) { @@ -399,4 +405,12 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable { return parent::getChanges($syncToken, $syncLevel, $limit); } + + public function restore(): void { + $this->caldavBackend->restoreCalendar((int) $this->calendarInfo['id']); + } + + public function disableTrashbin(): void { + $this->useTrashbin = false; + } } diff --git a/apps/dav/lib/CalDAV/CalendarHome.php b/apps/dav/lib/CalDAV/CalendarHome.php index c746ab04112..c418ff049c1 100644 --- a/apps/dav/lib/CalDAV/CalendarHome.php +++ b/apps/dav/lib/CalDAV/CalendarHome.php @@ -30,6 +30,7 @@ namespace OCA\DAV\CalDAV; use OCA\DAV\AppInfo\PluginManager; use OCA\DAV\CalDAV\Integration\ExternalCalendar; use OCA\DAV\CalDAV\Integration\ICalendarProvider; +use OCA\DAV\CalDAV\Trashbin\TrashbinHome; use Sabre\CalDAV\Backend\BackendInterface; use Sabre\CalDAV\Backend\NotificationSupport; use Sabre\CalDAV\Backend\SchedulingSupport; @@ -38,6 +39,7 @@ use Sabre\CalDAV\Schedule\Inbox; use Sabre\CalDAV\Subscriptions\Subscription; use Sabre\DAV\Exception\MethodNotAllowed; use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\INode; use Sabre\DAV\MkCol; class CalendarHome extends \Sabre\CalDAV\CalendarHome { @@ -74,8 +76,11 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { /** * @inheritdoc */ - public function createExtendedCollection($name, MkCol $mkCol) { - $reservedNames = [BirthdayService::BIRTHDAY_CALENDAR_URI]; + public function createExtendedCollection($name, MkCol $mkCol): void { + $reservedNames = [ + BirthdayService::BIRTHDAY_CALENDAR_URI, + TrashbinHome::NAME, + ]; if (\in_array($name, $reservedNames, true) || ExternalCalendar::doesViolateReservedName($name)) { throw new MethodNotAllowed('The resource you tried to create has a reserved name'); @@ -104,6 +109,10 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { $objects[] = new \Sabre\CalDAV\Notifications\Collection($this->caldavBackend, $this->principalInfo['uri']); } + if ($this->caldavBackend instanceof CalDavBackend) { + $objects[] = new TrashbinHome($this->caldavBackend, $this->principalInfo); + } + // If the backend supports subscriptions, we'll add those as well, if ($this->caldavBackend instanceof SubscriptionSupport) { foreach ($this->caldavBackend->getSubscriptionsForUser($this->principalInfo['uri']) as $subscription) { @@ -127,7 +136,9 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { } /** - * @inheritdoc + * @param string $name + * + * @return INode */ public function getChild($name) { // Special nodes @@ -140,6 +151,9 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { if ($name === 'notifications' && $this->caldavBackend instanceof NotificationSupport) { return new \Sabre\CalDAV\Notifications\Collection($this->caldavBackend, $this->principalInfo['uri']); } + if ($name === TrashbinHome::NAME && $this->caldavBackend instanceof CalDavBackend) { + return new TrashbinHome($this->caldavBackend, $this->principalInfo); + } // Calendars foreach ($this->caldavBackend->getCalendarsForUser($this->principalInfo['uri']) as $calendar) { diff --git a/apps/dav/lib/CalDAV/CalendarObject.php b/apps/dav/lib/CalDAV/CalendarObject.php index 766062ffdf1..0e42d9522ab 100644 --- a/apps/dav/lib/CalDAV/CalendarObject.php +++ b/apps/dav/lib/CalDAV/CalendarObject.php @@ -82,6 +82,10 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { return $vObject->serialize(); } + public function getId(): int { + return (int) $this->objectData['id']; + } + protected function isShared() { if (!isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal'])) { return false; diff --git a/apps/dav/lib/CalDAV/IRestorable.php b/apps/dav/lib/CalDAV/IRestorable.php new file mode 100644 index 00000000000..438098b5a0e --- /dev/null +++ b/apps/dav/lib/CalDAV/IRestorable.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\DAV\CalDAV; + +use Sabre\DAV\Exception; + +/** + * Interface for nodes that can be restored from the trashbin + */ +interface IRestorable { + + /** + * Restore this node + * + * @throws Exception + */ + public function restore(): void; +} diff --git a/apps/dav/lib/CalDAV/RetentionService.php b/apps/dav/lib/CalDAV/RetentionService.php new file mode 100644 index 00000000000..934e6f71530 --- /dev/null +++ b/apps/dav/lib/CalDAV/RetentionService.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\DAV\CalDAV; + +use OCA\DAV\AppInfo\Application; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; +use function max; + +class RetentionService { + public const RETENTION_CONFIG_KEY = 'calendarRetentionObligation'; + private const DEFAULT_RETENTION_SECONDS = 30 * 24 * 60 * 60; + + /** @var IConfig */ + private $config; + + /** @var ITimeFactory */ + private $time; + + /** @var CalDavBackend */ + private $calDavBackend; + + public function __construct(IConfig $config, + ITimeFactory $time, + CalDavBackend $calDavBackend) { + $this->config = $config; + $this->time = $time; + $this->calDavBackend = $calDavBackend; + } + + public function cleanUp(): void { + $retentionTime = max( + (int) $this->config->getAppValue( + Application::APP_ID, + self::RETENTION_CONFIG_KEY, + (string) self::DEFAULT_RETENTION_SECONDS + ), + 0 // Just making sure we don't delete things in the future when a negative number is passed + ); + $now = $this->time->getTime(); + + $calendars = $this->calDavBackend->getDeletedCalendars($now - $retentionTime); + foreach ($calendars as $calendar) { + $this->calDavBackend->deleteCalendar($calendar['id'], true); + } + + $objects = $this->calDavBackend->getDeletedCalendarObjects($now - $retentionTime); + foreach ($objects as $object) { + $this->calDavBackend->deleteCalendarObject( + $object['calendarid'], + $object['uri'], + $object['calendartype'], + true + ); + } + } +} diff --git a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php new file mode 100644 index 00000000000..43d96b52efd --- /dev/null +++ b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\DAV\CalDAV\Trashbin; + +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\IRestorable; +use Sabre\CalDAV\ICalendarObject; +use Sabre\DAV\Exception\Forbidden; + +class DeletedCalendarObject implements ICalendarObject, IRestorable { + + /** @var string */ + private $name; + + /** @var mixed[] */ + private $objectData; + + /** @var CalDavBackend */ + private $calDavBackend; + + public function __construct(string $name, + array $objectData, + CalDavBackend $calDavBackend) { + $this->name = $name; + $this->objectData = $objectData; + $this->calDavBackend = $calDavBackend; + } + + public function delete() { + throw new Forbidden(); + } + + public function getName() { + return $this->name; + } + + public function setName($name) { + throw new Forbidden(); + } + + public function getLastModified() { + return 0; + } + + public function put($data) { + throw new Forbidden(); + } + + public function get() { + return $this->objectData['calendardata']; + } + + public function getContentType() { + $mime = 'text/calendar; charset=utf-8'; + if (isset($this->objectData['component']) && $this->objectData['component']) { + $mime .= '; component='.$this->objectData['component']; + } + + return $mime; + } + + public function getETag() { + return $this->objectData['etag']; + } + + public function getSize() { + return (int) $this->objectData['size']; + } + + public function restore(): void { + $this->calDavBackend->restoreCalendarObject($this->objectData); + } +} diff --git a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php new file mode 100644 index 00000000000..2d79db03bce --- /dev/null +++ b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php @@ -0,0 +1,131 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\DAV\CalDAV\Trashbin; + +use OCA\DAV\CalDAV\CalDavBackend; +use Sabre\CalDAV\ICalendarObjectContainer; +use Sabre\DAV\Exception\BadRequest; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Exception\NotImplemented; +use function array_map; +use function implode; +use function preg_match; + +class DeletedCalendarObjectsCollection implements ICalendarObjectContainer { + public const NAME = 'objects'; + + /** @var CalDavBackend */ + protected $caldavBackend; + + /** @var mixed[] */ + private $principalInfo; + + public function __construct(CalDavBackend $caldavBackend, + array $principalInfo) { + $this->caldavBackend = $caldavBackend; + $this->principalInfo = $principalInfo; + } + + /** + * @see \OCA\DAV\CalDAV\Trashbin\DeletedCalendarObjectsCollection::calendarQuery + */ + public function getChildren() { + throw new NotImplemented(); + } + + public function getChild($name) { + if (!preg_match("/(\d+)\\.ics/", $name, $matches)) { + throw new NotFound(); + } + + $data = $this->caldavBackend->getCalendarObjectById( + $this->principalInfo['uri'], + (int) $matches[1], + ); + + // If the object hasn't been deleted yet then we don't want to find it here + if ($data === null) { + throw new NotFound(); + } + if (!isset($data['deleted_at'])) { + throw new BadRequest('The calendar object you\'re trying to restore is not marked as deleted'); + } + + return new DeletedCalendarObject( + $this->getRelativeObjectPath($data), + $data, + $this->caldavBackend + ); + } + + public function createFile($name, $data = null) { + throw new Forbidden(); + } + + public function createDirectory($name) { + throw new Forbidden(); + } + + public function childExists($name) { + try { + $this->getChild($name); + } catch (NotFound $e) { + return false; + } + + return true; + } + + public function delete() { + throw new Forbidden(); + } + + public function getName(): string { + return self::NAME; + } + + public function setName($name) { + throw new Forbidden(); + } + + public function getLastModified(): int { + return 0; + } + + public function calendarQuery(array $filters) { + return array_map(function (array $calendarInfo) { + return $this->getRelativeObjectPath($calendarInfo); + }, $this->caldavBackend->getDeletedCalendarObjectsByPrincipal($this->principalInfo['uri'])); + } + + private function getRelativeObjectPath(array $calendarInfo): string { + return implode( + '.', + [$calendarInfo['id'], 'ics'], + ); + } +} diff --git a/apps/dav/lib/CalDAV/Trashbin/Plugin.php b/apps/dav/lib/CalDAV/Trashbin/Plugin.php new file mode 100644 index 00000000000..d42a09f1c0d --- /dev/null +++ b/apps/dav/lib/CalDAV/Trashbin/Plugin.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\DAV\CalDAV\Trashbin; + +use OCA\DAV\CalDAV\Calendar; +use OCP\IRequest; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use function array_slice; +use function implode; + +/** + * Conditional logic to bypass the calendar trashbin + */ +class Plugin extends ServerPlugin { + + /** @var bool */ + private $disableTrashbin; + + /** @var Server */ + private $server; + + public function __construct(IRequest $request) { + $this->disableTrashbin = $request->getHeader('X-NC-CalDAV-No-Trashbin') === '1'; + } + + public function initialize(Server $server): void { + $this->server = $server; + $server->on('beforeMethod:*', [$this, 'beforeMethod']); + } + + public function beforeMethod(RequestInterface $request, ResponseInterface $response): void { + if (!$this->disableTrashbin) { + return; + } + + $path = $request->getPath(); + $pathParts = explode('/', ltrim($path, '/')); + if (\count($pathParts) < 3) { + // We are looking for a path like calendars/username/calendarname + return; + } + + // $calendarPath will look like calendars/username/calendarname + $calendarPath = implode( + '/', + array_slice($pathParts, 0, 3) + ); + try { + $calendar = $this->server->tree->getNodeForPath($calendarPath); + if (!($calendar instanceof Calendar)) { + // This is odd + return; + } + + /** @var Calendar $calendar */ + $calendar->disableTrashbin(); + } catch (NotFound $ex) { + return; + } + } + + public function getFeatures(): array { + return ['nc-calendar-trashbin']; + } + + public function getPluginName(): string { + return 'nc-calendar-trashbin'; + } +} diff --git a/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php b/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php new file mode 100644 index 00000000000..0175168e8d2 --- /dev/null +++ b/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\DAV\CalDAV\Trashbin; + +use OCA\DAV\CalDAV\IRestorable; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; +use Sabre\DAV\IMoveTarget; +use Sabre\DAV\INode; + +class RestoreTarget implements ICollection, IMoveTarget { + public const NAME = 'restore'; + + public function createFile($name, $data = null) { + throw new Forbidden(); + } + + public function createDirectory($name) { + throw new Forbidden(); + } + + public function getChild($name) { + throw new NotFound(); + } + + public function getChildren(): array { + return []; + } + + public function childExists($name): bool { + return false; + } + + public function moveInto($targetName, $sourcePath, INode $sourceNode): bool { + if ($sourceNode instanceof IRestorable) { + $sourceNode->restore(); + return true; + } + + return false; + } + + public function delete() { + throw new Forbidden(); + } + + public function getName(): string { + return 'restore'; + } + + public function setName($name) { + throw new Forbidden(); + } + + public function getLastModified() { + return 0; + } +} diff --git a/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php b/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php new file mode 100644 index 00000000000..87f1f24aaab --- /dev/null +++ b/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php @@ -0,0 +1,129 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\DAV\CalDAV\Trashbin; + +use OCA\DAV\CalDAV\CalDavBackend; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; +use Sabre\DAV\INode; +use Sabre\DAV\IProperties; +use Sabre\DAV\PropPatch; +use Sabre\DAV\Xml\Property\ResourceType; +use Sabre\DAVACL\ACLTrait; +use Sabre\DAVACL\IACL; +use function in_array; +use function sprintf; + +class TrashbinHome implements IACL, ICollection, IProperties { + use ACLTrait; + + public const NAME = 'trashbin'; + + /** @var CalDavBackend */ + private $caldavBackend; + + /** @var array */ + private $principalInfo; + + public function __construct(CalDavBackend $caldavBackend, + array $principalInfo) { + $this->caldavBackend = $caldavBackend; + $this->principalInfo = $principalInfo; + } + + public function getOwner(): string { + return $this->principalInfo['uri']; + } + + public function createFile($name, $data = null) { + throw new Forbidden('Permission denied to create files in the trashbin'); + } + + public function createDirectory($name) { + throw new Forbidden('Permission denied to create a directory in the trashbin'); + } + + public function getChild($name): INode { + switch ($name) { + case RestoreTarget::NAME: + return new RestoreTarget(); + case DeletedCalendarObjectsCollection::NAME: + return new DeletedCalendarObjectsCollection( + $this->caldavBackend, + $this->principalInfo + ); + } + + throw new NotFound(); + } + + public function getChildren(): array { + return [ + new RestoreTarget(), + new DeletedCalendarObjectsCollection( + $this->caldavBackend, + $this->principalInfo + ), + ]; + } + + public function childExists($name): bool { + return in_array($name, [ + RestoreTarget::NAME, + DeletedCalendarObjectsCollection::NAME, + ], true); + } + + public function delete() { + throw new Forbidden('Permission denied to delete the trashbin'); + } + + public function getName(): string { + return self::NAME; + } + + public function setName($name) { + throw new Forbidden('Permission denied to rename the trashbin'); + } + + public function getLastModified(): int { + return 0; + } + + public function propPatch(PropPatch $propPatch): void { + throw new Forbidden('not implemented'); + } + + public function getProperties($properties): array { + return [ + '{DAV:}resourcetype' => new ResourceType([ + '{DAV:}collection', + sprintf('{%s}trash-bin', \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD), + ]), + ]; + } +} |