diff options
author | Georg Ehrke <developer@georgehrke.com> | 2018-06-19 21:01:14 +0200 |
---|---|---|
committer | Roeland Jago Douma <roeland@famdouma.nl> | 2018-06-29 10:44:44 +0200 |
commit | 4aa4e4080c27356792b6eeab1f2f2b36ad485a05 (patch) | |
tree | 07197c21582d4e74cacce3704eaa5a67b5fb89bb /apps/dav/lib | |
parent | 3ff3141a1e4c9482ddaa68e13f545eb7e62ff9b7 (diff) | |
download | nextcloud-server-4aa4e4080c27356792b6eeab1f2f2b36ad485a05.tar.gz nextcloud-server-4aa4e4080c27356792b6eeab1f2f2b36ad485a05.zip |
Include accept / decline links in CalDAV invitation emails
Signed-off-by: Georg Ehrke <developer@georgehrke.com>
Diffstat (limited to 'apps/dav/lib')
5 files changed, 566 insertions, 6 deletions
diff --git a/apps/dav/lib/BackgroundJob/CleanupInvitationTokenJob.php b/apps/dav/lib/BackgroundJob/CleanupInvitationTokenJob.php new file mode 100644 index 00000000000..e855b57ee9f --- /dev/null +++ b/apps/dav/lib/BackgroundJob/CleanupInvitationTokenJob.php @@ -0,0 +1,53 @@ +<?php +declare(strict_types=1); +/** + * @copyright 2018, Georg Ehrke <oc.list@georgehrke.com> + * + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @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\BackgroundJob; + +use OC\BackgroundJob\TimedJob; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IDBConnection; + +class CleanupInvitationTokenJob extends TimedJob { + + /** @var IDBConnection */ + private $db; + + /** @var ITimeFactory */ + private $timeFactory; + + public function __construct(IDBConnection $db, ITimeFactory $timeFactory) { + $this->db = $db; + $this->timeFactory = $timeFactory; + + $this->setInterval(60 * 60 * 24); + } + + public function run($argument) { + $query = $this->db->getQueryBuilder(); + $query->delete('calendar_invitation_tokens') + ->where($query->expr()->lt('expiration', + $query->createNamedParameter($this->timeFactory->getTime()))) + ->execute(); + } +} diff --git a/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php new file mode 100644 index 00000000000..61ead99ce12 --- /dev/null +++ b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php @@ -0,0 +1,118 @@ +<?php +/** + * @copyright Copyright (c) 2018, Georg Ehrke. + * + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see <http://www.gnu.org/licenses/> + * + */ +namespace OCA\DAV\CalDAV\InvitationResponse; + +use OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin; +use OCA\DAV\Connector\Sabre\CachingTree; +use OCA\DAV\Connector\Sabre\DavAclPlugin; +use OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin; +use OCA\DAV\RootCollection; +use OCP\SabrePluginEvent; +use Sabre\DAV\Auth\Plugin; +use OCA\DAV\AppInfo\PluginManager; +use Sabre\VObject\ITip\Message; + +class InvitationResponseServer { + + /** @var \OCA\DAV\Connector\Sabre\Server */ + public $server; + + /** + * InvitationResponseServer constructor. + */ + public function __construct() { + $baseUri = \OC::$WEBROOT . '/remote.php/dav/'; + $logger = \OC::$server->getLogger(); + $dispatcher = \OC::$server->getEventDispatcher(); + + $root = new RootCollection(); + $this->server = new \OCA\DAV\Connector\Sabre\Server(new CachingTree($root)); + + // Add maintenance plugin + $this->server->addPlugin(new \OCA\DAV\Connector\Sabre\MaintenancePlugin(\OC::$server->getConfig())); + + // Set URL explicitly due to reverse-proxy situations + $this->server->httpRequest->setUrl($baseUri); + $this->server->setBaseUri($baseUri); + + $this->server->addPlugin(new BlockLegacyClientPlugin(\OC::$server->getConfig())); + $this->server->addPlugin(new AnonymousOptionsPlugin()); + $this->server->addPlugin(new class() extends Plugin { + public function getCurrentPrincipal() { + return 'principals/system/public'; + } + }); + + // allow setup of additional auth backends + $event = new SabrePluginEvent($this->server); + $dispatcher->dispatch('OCA\DAV\Connector\Sabre::authInit', $event); + + $this->server->addPlugin(new \OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin('webdav', $logger)); + $this->server->addPlugin(new \OCA\DAV\Connector\Sabre\LockPlugin()); + $this->server->addPlugin(new \Sabre\DAV\Sync\Plugin()); + + // acl + $acl = new DavAclPlugin(); + $acl->principalCollectionSet = [ + 'principals/users', 'principals/groups' + ]; + $acl->defaultUsernamePath = 'principals/users'; + $this->server->addPlugin($acl); + + // calendar plugins + $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin()); + $this->server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin()); + $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin()); + $this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin()); + $this->server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin()); + //$this->server->addPlugin(new \OCA\DAV\DAV\Sharing\Plugin($authBackend, \OC::$server->getRequest())); + $this->server->addPlugin(new \OCA\DAV\CalDAV\Publishing\PublishPlugin( + \OC::$server->getConfig(), + \OC::$server->getURLGenerator() + )); + + // wait with registering these until auth is handled and the filesystem is setup + $this->server->on('beforeMethod', function () use ($root) { + // register plugins from apps + $pluginManager = new PluginManager( + \OC::$server, + \OC::$server->getAppManager() + ); + foreach ($pluginManager->getAppPlugins() as $appPlugin) { + $this->server->addPlugin($appPlugin); + } + foreach ($pluginManager->getAppCollections() as $appCollection) { + $root->addChild($appCollection); + } + }); + } + + /** + * @param Message $iTipMessage + * @return void + */ + public function handleITipMessage(Message $iTipMessage) { + /** @var \OCA\DAV\CalDAV\Schedule\Plugin $schedulingPlugin */ + $schedulingPlugin = $this->server->getPlugin('caldav-schedule'); + $schedulingPlugin->scheduleLocalDelivery($iTipMessage); + } +}
\ No newline at end of file diff --git a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php index 85973a8be12..40b4ee355f3 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php @@ -28,12 +28,14 @@ namespace OCA\DAV\CalDAV\Schedule; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Defaults; use OCP\IConfig; +use OCP\IDBConnection; use OCP\IL10N; use OCP\ILogger; use OCP\IURLGenerator; use OCP\L10N\IFactory as L10NFactory; use OCP\Mail\IEMailTemplate; use OCP\Mail\IMailer; +use OCP\Security\ISecureRandom; use Sabre\CalDAV\Schedule\IMipPlugin as SabreIMipPlugin; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VEvent; @@ -79,6 +81,12 @@ class IMipPlugin extends SabreIMipPlugin { /** @var IURLGenerator */ private $urlGenerator; + /** @var ISecureRandom */ + private $random; + + /** @var IDBConnection */ + private $db; + /** @var Defaults */ private $defaults; @@ -96,9 +104,14 @@ class IMipPlugin extends SabreIMipPlugin { * @param L10NFactory $l10nFactory * @param IUrlGenerator $urlGenerator * @param Defaults $defaults + * @param ISecureRandom $random + * @param IDBConnection $db * @param string $userId */ - public function __construct(IConfig $config, IMailer $mailer, ILogger $logger, ITimeFactory $timeFactory, L10NFactory $l10nFactory, IURLGenerator $urlGenerator, Defaults $defaults, $userId) { + public function __construct(IConfig $config, IMailer $mailer, ILogger $logger, + ITimeFactory $timeFactory, L10NFactory $l10nFactory, + IURLGenerator $urlGenerator, Defaults $defaults, + ISecureRandom $random, IDBConnection $db, $userId) { parent::__construct(''); $this->userId = $userId; $this->config = $config; @@ -107,6 +120,8 @@ class IMipPlugin extends SabreIMipPlugin { $this->timeFactory = $timeFactory; $this->l10nFactory = $l10nFactory; $this->urlGenerator = $urlGenerator; + $this->random = $random; + $this->db = $db; $this->defaults = $defaults; } @@ -138,7 +153,9 @@ class IMipPlugin extends SabreIMipPlugin { } // don't send out mails for events that already took place - if ($this->isEventInThePast($iTipMessage->message)) { + $lastOccurrence = $this->getLastOccurrence($iTipMessage->message); + $currentTime = $this->timeFactory->getTime(); + if ($lastOccurrence < $currentTime) { return; } @@ -222,6 +239,7 @@ class IMipPlugin extends SabreIMipPlugin { $meetingAttendeeName, $meetingInviteeName); $this->addBulletList($template, $l10n, $meetingWhen, $meetingLocation, $meetingDescription, $meetingUrl); + $this->addResponseButtons($template, $l10n, $iTipMessage, $lastOccurrence); $template->addFooter(); $message->useTemplate($template); @@ -249,9 +267,9 @@ class IMipPlugin extends SabreIMipPlugin { /** * check if event took place in the past already * @param VCalendar $vObject - * @return bool + * @return int */ - private function isEventInThePast(VCalendar $vObject) { + private function getLastOccurrence(VCalendar $vObject) { /** @var VEvent $component */ $component = $vObject->VEVENT; @@ -291,8 +309,7 @@ class IMipPlugin extends SabreIMipPlugin { } } - $currentTime = $this->timeFactory->getTime(); - return $lastOccurrence < $currentTime; + return $lastOccurrence; } @@ -460,6 +477,38 @@ class IMipPlugin extends SabreIMipPlugin { } /** + * @param IEMailTemplate $template + * @param IL10N $l10n + * @param Message $iTipMessage + * @param int $lastOccurrence + */ + private function addResponseButtons(IEMailTemplate $template, IL10N $l10n, + Message $iTipMessage, $lastOccurrence) { + $token = $this->createInvitationToken($iTipMessage, $lastOccurrence); + + $template->addBodyButtonGroup( + $l10n->t('Accept'), + $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [ + 'token' => $token, + ]), + $l10n->t('Decline'), + $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [ + 'token' => $token, + ]) + ); + + $moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [ + 'token' => $token, + ]); + $html = vsprintf('<small><a href="%s">%s</a></small>', [ + $moreOptionsURL, $l10n->t('More options ...') + ]); + $text = $l10n->t('More options at %s', [$moreOptionsURL]); + + $template->addBodyText($html, $text); + } + + /** * @param string $path * @return string */ @@ -468,4 +517,37 @@ class IMipPlugin extends SabreIMipPlugin { $this->urlGenerator->imagePath('core', $path) ); } + + /** + * @param Message $iTipMessage + * @param int $lastOccurrence + * @return string + */ + private function createInvitationToken(Message $iTipMessage, $lastOccurrence):string { + $token = $this->random->generate(60, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS); + + /** @var VEvent $vevent */ + $vevent = $iTipMessage->message->VEVENT; + $attendee = $iTipMessage->recipient; + $organizer = $iTipMessage->sender; + $sequence = $iTipMessage->sequence; + $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? + $vevent->{'RECURRENCE-ID'}->serialize() : null; + $uid = $vevent->{'UID'}; + + $query = $this->db->getQueryBuilder(); + $query->insert('calendar_invitation_tokens') + ->values([ + 'token' => $query->createNamedParameter($token), + 'attendee' => $query->createNamedParameter($attendee), + 'organizer' => $query->createNamedParameter($organizer), + 'sequence' => $query->createNamedParameter($sequence), + 'recurrenceid' => $query->createNamedParameter($recurrenceId), + 'expiration' => $query->createNamedParameter($lastOccurrence), + 'uid' => $query->createNamedParameter($uid) + ]) + ->execute(); + + return $token; + } } diff --git a/apps/dav/lib/Controller/InvitationResponseController.php b/apps/dav/lib/Controller/InvitationResponseController.php new file mode 100644 index 00000000000..08298a29f0f --- /dev/null +++ b/apps/dav/lib/Controller/InvitationResponseController.php @@ -0,0 +1,236 @@ +<?php +declare(strict_types=1); +/** + * @copyright 2018, Georg Ehrke <oc.list@georgehrke.com> + * + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @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\Controller; + +use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IDBConnection; +use OCP\IRequest; +use Sabre\VObject\ITip\Message; +use Sabre\VObject\Reader; + +class InvitationResponseController extends Controller { + + /** @var IDBConnection */ + private $db; + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var InvitationResponseServer */ + private $responseServer; + + /** + * InvitationResponseController constructor. + * + * @param string $appName + * @param IRequest $request + * @param IDBConnection $db + * @param ITimeFactory $timeFactory + * @param InvitationResponseServer $responseServer + */ + public function __construct(string $appName, IRequest $request, + IDBConnection $db, ITimeFactory $timeFactory, + InvitationResponseServer $responseServer) { + parent::__construct($appName, $request); + $this->db = $db; + $this->timeFactory = $timeFactory; + $this->responseServer = $responseServer; + // Don't run `$server->exec()`, because we just need access to the + // fully initialized schedule plugin, but we don't want Sabre/DAV + // to actually handle and reply to the request + } + + /** + * @PublicPage + * @NoCSRFRequired + * + * @param string $token + * @return TemplateResponse + */ + public function accept(string $token):TemplateResponse { + $row = $this->getTokenInformation($token); + if (!$row) { + return new TemplateResponse($this->appName, 'schedule-response-error', [], 'guest'); + } + + $iTipMessage = $this->buildITipResponse($row, 'ACCEPTED'); + $this->responseServer->handleITipMessage($iTipMessage); + if ($iTipMessage->getScheduleStatus() === '1.2') { + return new TemplateResponse($this->appName, 'schedule-response-success', [], 'guest'); + } + + return new TemplateResponse($this->appName, 'schedule-response-error', [ + 'organizer' => $row['organizer'], + ], 'guest'); + } + + /** + * @PublicPage + * @NoCSRFRequired + * + * @param string $token + * @return TemplateResponse + */ + public function decline(string $token):TemplateResponse { + $row = $this->getTokenInformation($token); + if (!$row) { + return new TemplateResponse($this->appName, 'schedule-response-error', [], 'guest'); + } + + $iTipMessage = $this->buildITipResponse($row, 'DECLINED'); + $this->responseServer->handleITipMessage($iTipMessage); + + if ($iTipMessage->getScheduleStatus() === '1.2') { + return new TemplateResponse($this->appName, 'schedule-response-success', [], 'guest'); + } + + return new TemplateResponse($this->appName, 'schedule-response-error', [ + 'organizer' => $row['organizer'], + ], 'guest'); + } + + /** + * @PublicPage + * @NoCSRFRequired + * + * @param string $token + * @return TemplateResponse + */ + public function options(string $token):TemplateResponse { + return new TemplateResponse($this->appName, 'schedule-response-options', [ + 'token' => $token + ], 'guest'); + } + + /** + * @PublicPage + * @NoCSRFRequired + * + * @param string $token + * + * @return TemplateResponse + */ + public function processMoreOptionsResult(string $token):TemplateResponse { + $partstat = $this->request->getParam('partStat'); + $guests = (int) $this->request->getParam('guests'); + $comment = $this->request->getParam('comment'); + + $row = $this->getTokenInformation($token); + if (!$row || !\in_array($partstat, ['ACCEPTED', 'DECLINED', 'TENTATIVE'])) { + return new TemplateResponse($this->appName, 'schedule-response-error', [], 'guest'); + } + + $iTipMessage = $this->buildITipResponse($row, $partstat, $guests, $comment); + $this->responseServer->handleITipMessage($iTipMessage); + if ($iTipMessage->getScheduleStatus() === '1.2') { + return new TemplateResponse($this->appName, 'schedule-response-success', [], 'guest'); + } + + return new TemplateResponse($this->appName, 'schedule-response-error', [ + 'organizer' => $row['organizer'], + ], 'guest'); + } + + /** + * @param string $token + * @return array|null + */ + private function getTokenInformation(string $token) { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from('calendar_invitation_tokens') + ->where($query->expr()->eq('token', $query->createNamedParameter($token))); + $stmt = $query->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + if(!$row) { + return null; + } + + $currentTime = $this->timeFactory->getTime(); + if (((int) $row['expiration']) < $currentTime) { + return null; + } + + return $row; + } + + /** + * @param array $row + * @param string $partStat participation status of attendee - SEE RFC 5545 + * @param int|null $guests + * @param string|null $comment + * @return Message + */ + private function buildITipResponse(array $row, string $partStat, int $guests=null, + string $comment=null):Message { + $iTipMessage = new Message(); + $iTipMessage->uid = $row['uid']; + $iTipMessage->component = 'VEVENT'; + $iTipMessage->method = 'REPLY'; + $iTipMessage->sequence = $row['sequence']; + $iTipMessage->sender = $row['attendee']; + $iTipMessage->recipient = $row['organizer']; + + $message = <<<EOF +BEGIN:VCALENDAR +PRODID:-//Nextcloud/Nextcloud CalDAV Server//EN +METHOD:REPLY +VERSION:2.0 +BEGIN:VEVENT +ATTENDEE;PARTSTAT=%s:%s +ORGANIZER:%s +UID:%s +SEQUENCE:%s +REQUEST-STATUS:2.0;Success +%sEND:VEVENT +END:VCALENDAR +EOF; + + $vObject = Reader::read(vsprintf($message, [ + $partStat, $row['attendee'], $row['organizer'], + $row['uid'], $row['sequence'] ?? 0, $row['recurrenceid'] ?? '' + ])); + $vEvent = $vObject->{'VEVENT'}; + /** @var \Sabre\VObject\Property\ICalendar\CalAddress $attendee */ + $attendee = $vEvent->{'ATTENDEE'}; + + $vEvent->DTSTAMP = date('Ymd\\THis\\Z', $this->timeFactory->getTime()); + + if ($comment) { + $attendee->add('X-RESPONSE-COMMENT', $comment); + $vEvent->add('COMMENT', $comment); + } + if ($guests) { + $attendee->add('X-NUM-GUESTS', $guests); + } + + $iTipMessage->message = $vObject; + + return $iTipMessage; + } +} diff --git a/apps/dav/lib/Migration/Version1006Date20180619154313.php b/apps/dav/lib/Migration/Version1006Date20180619154313.php new file mode 100644 index 00000000000..8fb82ffed67 --- /dev/null +++ b/apps/dav/lib/Migration/Version1006Date20180619154313.php @@ -0,0 +1,71 @@ +<?php +namespace OCA\DAV\Migration; + +use Doctrine\DBAL\Types\Type; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\SimpleMigrationStep; +use OCP\Migration\IOutput; + +/** + * Auto-generated migration step: Please modify to your needs! + */ +class Version1006Date20180619154313 extends SimpleMigrationStep { + + /** + * @param IOutput $output + * @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + * @since 13.0.0 + */ + public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('calendar_invitation_tokens')) { + $table = $schema->createTable('calendar_invitation_tokens'); + + $table->addColumn('id', Type::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 11, + 'unsigned' => true, + ]); + $table->addColumn('uid', Type::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('recurrenceid', Type::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $table->addColumn('attendee', Type::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('organizer', Type::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('sequence', Type::BIGINT, [ + 'notnull' => false, + 'length' => 11, + 'unsigned' => true, + ]); + $table->addColumn('token', Type::STRING, [ + 'notnull' => true, + 'length' => 60, + ]); + $table->addColumn('expiration', Type::BIGINT, [ + 'notnull' => true, + 'length' => 11, + 'unsigned' => true, + ]); + + $table->setPrimaryKey(['id'], 'calendar_invitation_tokens_id_idx'); + $table->addIndex(['token'], 'calendar_invitation_tokens_token_idx'); + + return $schema; + } + } +} |