diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php index 0a347b7e..1dd2cea9 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php @@ -1,358 +1,358 @@ <?php /** * Kolab 2-Factor-Authentication Driver base class * * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com> * * 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 Kolab2FA\Driver; /** * Kolab 2-Factor-Authentication Driver base class * - * @property string $username + * @property string $username * @property string $secret */ abstract class Base { public $method; public $id; public $storage; protected $config = []; protected $props = []; protected $user_props = []; protected $pending_changes = false; protected $temporary = false; protected $allowed_props = ['username']; public $user_settings = [ 'active' => [ 'type' => 'boolean', 'editable' => false, 'hidden' => false, 'default' => false, ], 'label' => [ 'type' => 'text', 'editable' => true, 'label' => 'label', 'generator' => 'default_label', ], 'created' => [ 'type' => 'datetime', 'editable' => false, 'hidden' => false, 'label' => 'created', 'generator' => 'time', ], ]; /** * Static factory method */ public static function factory($id, $config) { [$method] = explode(':', $id); $classmap = [ 'totp' => '\\Kolab2FA\\Driver\\TOTP', 'hotp' => '\\Kolab2FA\\Driver\\HOTP', 'yubikey' => '\\Kolab2FA\\Driver\\Yubikey', ]; $cls = $classmap[strtolower($method)]; if ($cls && class_exists($cls)) { return new $cls($config, $id); } throw new Exception("Unknown 2FA driver '$method'"); } /** * Default constructor */ public function __construct($config = null, $id = null) { $this->init($config); if (!empty($id) && $id != $this->method) { $this->id = $id; } else { // generate random ID $this->id = $this->method . ':' . bin2hex(openssl_random_pseudo_bytes(12)); $this->temporary = true; } } /** * Initialize the driver with the given config options */ public function init($config) { if (is_array($config)) { $this->config = array_merge($this->config, $config); } if (!empty($config['storage'])) { $this->storage = \Kolab2FA\Storage\Base::factory($config['storage'], $config['storage_config']); } } /** * Verify the submitted authentication code * * @param string $code The 2nd authentication factor to verify * @param int $timestamp Timestamp of authentication process (window start) * * @return bool True if valid, false otherwise */ abstract public function verify($code, $timestamp = null); /** * Getter for user-visible properties */ public function props($force = false) { $data = []; foreach ($this->user_settings as $key => $p) { if (!empty($p['private'])) { continue; } $data[$key] = [ 'type' => $p['type'], 'editable' => $p['editable'] ?? false, 'hidden' => $p['hidden'] ?? false, 'label' => $p['label'] ?? '', 'value' => $this->get($key, $force), ]; // format value into text switch ($p['type']) { case 'boolean': $data[$key]['value'] = (bool)$data[$key]['value']; $data[$key]['text'] = $data[$key]['value'] ? 'yes' : 'no'; break; case 'datetime': if (is_numeric($data[$key]['value'])) { $data[$key]['text'] = date('c', $data[$key]['value']); break; } // no break default: $data[$key]['text'] = $data[$key]['value']; } } return $data; } /** * Implement this method if the driver can be provisioned via QR code */ /* abstract function get_provisioning_uri(); */ /** * Generate a random secret string */ public function generate_secret($length = 16) { // Base32 characters $chars = [ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23 'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31 ]; $secret = ''; for ($i = 0; $i < $length; $i++) { $secret .= $chars[array_rand($chars)]; } return $secret; } /** * Generate the default label based on the method */ public function default_label() { if (class_exists('\\rcmail', false)) { return \rcmail::get_instance()->gettext($this->method, 'kolab_2fa'); } return strtoupper($this->method); } /** * Getter for read-only access to driver properties */ public function get($key, $force = false) { // this is a per-user property: get from persistent storage if (isset($this->user_settings[$key])) { $value = $this->get_user_prop($key); // generate property value if (!isset($value) && $force && !empty($this->user_settings[$key]['generator'])) { $func = $this->user_settings[$key]['generator']; if (is_string($func) && !is_callable($func)) { $func = [$this, $func]; } if (is_callable($func)) { $value = call_user_func($func); } if (isset($value)) { $this->set_user_prop($key, $value); } } } else { $value = $this->props[$key] ?? null; } return $value; } /** * Setter for restricted access to driver properties */ public function set($key, $value, $persistent = true) { // store as per-user property if (isset($this->user_settings[$key])) { if ($persistent) { return $this->set_user_prop($key, $value); } $this->user_props[$key] = $value; } $setter = 'set_' . $key; if (method_exists($this, $setter)) { call_user_func([$this, $setter], $value); } elseif (in_array($key, $this->allowed_props)) { $this->props[$key] = $value; } return true; } /** * Commit changes to storage */ public function commit() { if (!empty($this->user_props) && $this->storage && $this->pending_changes) { if ($this->storage->write($this->id, $this->user_props)) { $this->pending_changes = false; $this->temporary = false; } } return !$this->pending_changes; } /** * Dedicated setter for the username property */ public function set_username($username) { $this->props['username'] = $username; if ($this->storage) { $this->storage->set_username($username); } return true; } /** * Clear data stored for this driver */ public function clear() { if ($this->storage) { return $this->storage->remove($this->id); } return false; } /** * Checks that a string contains a semicolon */ protected function hasSemicolon($value) { return preg_match('/(:|%3A)/i', (string) $value) > 0; } /** * Getter for per-user properties for this method */ protected function get_user_prop($key) { if (!isset($this->user_props[$key]) && $this->storage && !$this->pending_changes && !$this->temporary) { $this->user_props = (array)$this->storage->read($this->id); } return $this->user_props[$key] ?? null; } /** * Setter for per-user properties for this method */ protected function set_user_prop($key, $value) { $this->pending_changes |= (($this->user_props[$key] ?? null) !== $value); $this->user_props[$key] = $value; return true; } /** * Magic getter for read-only access to driver properties */ public function __get($key) { // this is a per-user property: get from persistent storage if (isset($this->user_settings[$key])) { return $this->get_user_prop($key); } return $this->props[$key]; } /** * Magic setter for restricted access to driver properties */ public function __set($key, $value) { $this->set($key, $value, false); } /** * Magic check if driver property is defined */ public function __isset($key) { return isset($this->props[$key]); } } diff --git a/plugins/libcalendaring/lib/libcalendaring_recurrence.php b/plugins/libcalendaring/lib/libcalendaring_recurrence.php index 3b06bd01..15bd77ea 100644 --- a/plugins/libcalendaring/lib/libcalendaring_recurrence.php +++ b/plugins/libcalendaring/lib/libcalendaring_recurrence.php @@ -1,296 +1,318 @@ <?php use Sabre\VObject\Recur\EventIterator; +use Sabre\VObject\Recur\NoInstancesException; /** * Recurrence computation class for shared use. * * Uitility class to compute recurrence dates from the given rules. * * @author Aleksander Machniak <machniak@apheleia-it.ch * * Copyright (C) 2012-2022, Apheleia IT AG <contact@apheleia-it.ch> * * 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/>. */ class libcalendaring_recurrence { protected $lib; protected $start; protected $engine; protected $recurrence; protected $dateonly = false; protected $event; protected $duration; protected $isStart = true; /** * Default constructor * * @param libcalendaring $lib The libcalendaring plugin instance * @param array $event The event object to operate on */ public function __construct($lib, $event = null) { $this->lib = $lib; $this->event = $event; if (!empty($event)) { if (!empty($event['start']) && is_object($event['start']) && !empty($event['end']) && is_object($event['end']) ) { $this->duration = $event['start']->diff($event['end']); } $this->init($event['recurrence'], $event['start']); } } /** * Initialize recurrence engine * * @param array $recurrence The recurrence properties * @param DateTime $start The recurrence start date */ public function init($recurrence, $start) { $this->start = $start; $this->dateonly = !empty($start->_dateonly) || !empty($this->event['allday']); $this->recurrence = $recurrence; $event = [ 'uid' => '1', 'allday' => $this->dateonly, 'recurrence' => $recurrence, 'start' => $start, // TODO: END/DURATION ??? // TODO: moved occurrences ??? ]; $vcalendar = new libcalendaring_vcalendar($this->lib->timezone); $ve = $vcalendar->toSabreComponent($event); - $this->engine = new EventIterator($ve, null, $this->lib->timezone); + try { + $this->engine = new EventIterator($ve, null, $this->lib->timezone); + } catch (NoInstancesException $e) { + // We treat a non-recurring event silently + // TODO: What about other exceptions? + } } /** * Get date/time of the next occurence of this event, and push the iterator. * - * @return DateTime|false object or False if recurrence ended + * @return libcalendaring_datetime|false object or False if recurrence ended */ public function next_start() { + if (empty($this->engine)) { + return false; + } + try { $this->engine->next(); $current = $this->engine->getDtStart(); } catch (Exception $e) { // do nothing } return !empty($current) ? $this->toDateTime($current) : false; } /** * Get the next recurring instance of this event * * @return array|false Array with event properties or False if recurrence ended */ public function next_instance() { + if (empty($this->engine)) { + return false; + } + // Here's the workaround for an issue for an event with its start date excluded // E.g. A daily event starting on 10th which is one of EXDATE dates // should return 11th as next_instance() when called for the first time. // Looks like Sabre is setting internal "current date" to 11th on such an object // initialization, therefore calling next() would move it to 12th. if ($this->isStart && ($next_start = $this->engine->getDtStart()) && $next_start->format('Ymd') != $this->start->format('Ymd') ) { $next_start = $this->toDateTime($next_start); } else { $next_start = $this->next_start(); } $this->isStart = false; if ($next_start) { $next = $this->event; $next['start'] = $next_start; if ($this->duration) { $next['end'] = clone $next_start; $next['end']->add($this->duration); } $next['recurrence_date'] = clone $next_start; $next['_instance'] = libcalendaring::recurrence_instance_identifier($next); unset($next['_formatobj']); return $next; } return false; } /** * Get the date of the end of the last occurrence of this recurrence cycle * - * @return DateTime|false End datetime of the last occurrence or False if there's no end date + * @return libcalendaring_datetime|false End datetime of the last occurrence or False if there's no end date */ public function end() { + if (empty($this->engine)) { + return $this->toDateTime($this->start); + } + // recurrence end date is given if (isset($this->recurrence['UNTIL']) && $this->recurrence['UNTIL'] instanceof DateTimeInterface) { return $this->toDateTime($this->recurrence['UNTIL']); } // Run through all items till we reach the end, or limit of iterations // Note: Sabre has a limits of iteration in VObject\Settings, so it is not an infinite loop try { foreach ($this->engine as $end) { // do nothing } } catch (Exception $e) { // do nothing } /* if (empty($end) && isset($this->event['start']) && $this->event['start'] instanceof DateTimeInterface) { // determine a reasonable end date if none given $end = clone $this->event['start']; $end->add(new DateInterval('P100Y')); } */ return isset($end) ? $this->toDateTime($end) : false; } /** * Find date/time of the first occurrence (excluding start date) * - * @return DateTime|null First occurrence + * @return libcalendaring_datetime|null First occurrence */ public function first_occurrence() { + if (empty($this->engine)) { + return $this->toDateTime($this->start); + } + $start = clone $this->start; $interval = $this->recurrence['INTERVAL'] ?? 1; $freq = $this->recurrence['FREQ'] ?? null; switch ($freq) { case 'WEEKLY': if (empty($this->recurrence['BYDAY'])) { return $start; } $start->sub(new DateInterval("P{$interval}W")); break; case 'MONTHLY': if (empty($this->recurrence['BYDAY']) && empty($this->recurrence['BYMONTHDAY'])) { return $start; } $start->sub(new DateInterval("P{$interval}M")); break; case 'YEARLY': if (empty($this->recurrence['BYDAY']) && empty($this->recurrence['BYMONTH'])) { return $start; } $start->sub(new DateInterval("P{$interval}Y")); break; case 'DAILY': if (!empty($this->recurrence['BYMONTH'])) { break; } // no break default: return $start; } $recurrence = $this->recurrence; if (!empty($recurrence['COUNT'])) { // Increase count so we do not stop the loop to early $recurrence['COUNT'] += 100; } // Create recurrence that starts in the past $self = new self($this->lib); $self->init($recurrence, $start); // TODO: This method does not work the same way as the kolab_date_recurrence based on // kolabcalendaring. I.e. if an event start date does not match the recurrence rule // it will be returned, kolab_date_recurrence will return the next occurrence in such a case // which is the intended result of this function. // See some commented out test cases in tests/RecurrenceTest.php // find the first occurrence $found = false; while ($next = $self->next_start()) { $start = $next; if ($next >= $this->start) { $found = true; break; } } if (!$found) { rcube::raise_error( [ 'file' => __FILE__, 'line' => __LINE__, 'message' => sprintf( "Failed to find a first occurrence. Start: %s, Recurrence: %s", $this->start->format(DateTime::ISO8601), json_encode($recurrence) ), ], true ); return null; } return $this->toDateTime($start); } /** * Convert any DateTime into libcalendaring_datetime */ protected function toDateTime($date, $useStart = true) { if ($date instanceof DateTimeInterface) { $date = libcalendaring_datetime::createFromFormat( 'Y-m-d\\TH:i:s', $date->format('Y-m-d\\TH:i:s'), // Sabre will loose timezone on all-day events, use the event start's timezone $this->start->getTimezone() ); } $date->_dateonly = $this->dateonly; if ($useStart && $this->dateonly) { // Sabre sets time to 00:00:00 for all-day events, // let's copy the time from the event's start $date->setTime((int) $this->start->format('H'), (int) $this->start->format('i'), (int) $this->start->format('s')); } return $date; } } diff --git a/plugins/libcalendaring/tests/RecurrenceTest.php b/plugins/libcalendaring/tests/RecurrenceTest.php index f8e3b285..f2dc3c16 100644 --- a/plugins/libcalendaring/tests/RecurrenceTest.php +++ b/plugins/libcalendaring/tests/RecurrenceTest.php @@ -1,433 +1,473 @@ <?php /** * libcalendaring_recurrence tests * * @author Aleksander Machniak <machniak@apheleia-it.ch> * * Copyright (C) 2022, Apheleia IT AG <contact@apheleia-it.ch> * * 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/>. */ class RecurrenceTest extends PHPUnit\Framework\TestCase { private $plugin; public function setUp(): void { $rcube = rcmail::get_instance(); $rcube->plugins->load_plugin('libcalendaring', true, true); $this->plugin = $rcube->plugins->get_plugin('libcalendaring'); } /** * Data for test_end() */ public function data_end() { return [ // non-recurring [ [ 'recurrence' => [], 'start' => new DateTime('2017-08-31 11:00:00'), ], '2017-08-31 11:00:00', // expected result ], // daily [ [ 'recurrence' => ['FREQ' => 'DAILY', 'INTERVAL' => '1', 'COUNT' => 2], 'start' => new DateTime('2017-08-31 11:00:00'), ], '2017-09-01 11:00:00', ], // weekly [ [ 'recurrence' => ['FREQ' => 'WEEKLY', 'COUNT' => 3], 'start' => new DateTime('2017-08-31 11:00:00'), // Thursday ], '2017-09-14 11:00:00', ], // UNTIL [ [ 'recurrence' => ['FREQ' => 'WEEKLY', 'COUNT' => 3, 'UNTIL' => new DateTime('2017-09-07 11:00:00')], 'start' => new DateTime('2017-08-31 11:00:00'), // Thursday ], '2017-09-07 11:00:00', ], // Infinite recurrence, no count, no until [ [ 'recurrence' => ['FREQ' => 'WEEKLY', 'INTERVAL' => '1'], 'start' => new DateTime('2017-08-31 11:00:00'), // Thursday ], '2084-09-21 11:00:00', ], // TODO: Test an event with EXDATE/RDATEs ]; } /** * Data for test_first_occurrence() */ public function data_first_occurrence() { // TODO: BYYEARDAY, BYWEEKNO, BYSETPOS, WKST return [ // non-recurring [ [], // recurrence data '2017-08-31 11:00:00', // start date '2017-08-31 11:00:00', // expected result ], // daily [ ['FREQ' => 'DAILY', 'INTERVAL' => '1'], // recurrence data '2017-08-31 11:00:00', // start date '2017-08-31 11:00:00', // expected result ], // TODO: this one is not supported by the Calendar UI /* array( array('FREQ' => 'DAILY', 'INTERVAL' => '1', 'BYMONTH' => 1), '2017-08-31 11:00:00', '2018-01-01 11:00:00', ), */ // weekly [ ['FREQ' => 'WEEKLY', 'INTERVAL' => '1'], '2017-08-31 11:00:00', // Thursday '2017-08-31 11:00:00', ], [ ['FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE'], '2017-08-31 11:00:00', // Thursday '2017-09-06 11:00:00', ], [ ['FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'TH'], '2017-08-31 11:00:00', // Thursday '2017-08-31 11:00:00', ], [ ['FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'FR'], '2017-08-31 11:00:00', // Thursday '2017-09-01 11:00:00', ], [ ['FREQ' => 'WEEKLY', 'INTERVAL' => '2'], '2017-08-31 11:00:00', // Thursday '2017-08-31 11:00:00', ], [ ['FREQ' => 'WEEKLY', 'INTERVAL' => '3', 'BYDAY' => 'WE'], '2017-08-31 11:00:00', // Thursday '2017-09-20 11:00:00', ], [ ['FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE', 'COUNT' => 1], '2017-08-31 11:00:00', // Thursday '2017-09-06 11:00:00', ], [ ['FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE', 'UNTIL' => '2017-09-01'], '2017-08-31 11:00:00', // Thursday '', ], // monthly [ ['FREQ' => 'MONTHLY', 'INTERVAL' => '1'], '2017-09-08 11:00:00', '2017-09-08 11:00:00', ], [ ['FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'], '2017-08-31 11:00:00', '2017-09-08 11:00:00', ], [ ['FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'], '2017-09-08 11:00:00', '2017-09-08 11:00:00', ], [ ['FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYDAY' => '1WE'], '2017-08-16 11:00:00', '2017-09-06 11:00:00', ], [ ['FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYDAY' => '-1WE'], '2017-08-16 11:00:00', '2017-08-30 11:00:00', ], [ ['FREQ' => 'MONTHLY', 'INTERVAL' => '2'], '2017-09-08 11:00:00', '2017-09-08 11:00:00', ], [ ['FREQ' => 'MONTHLY', 'INTERVAL' => '2', 'BYMONTHDAY' => '8'], '2017-08-31 11:00:00', '2017-09-08 11:00:00', // ?????? ], // yearly [ ['FREQ' => 'YEARLY', 'INTERVAL' => '1'], '2017-08-16 12:00:00', '2017-08-16 12:00:00', ], [ ['FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '8'], '2017-08-16 12:00:00', '2017-08-16 12:00:00', ], /* // Not supported by Sabre (requires BYMONTH too) array( array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYDAY' => '-1MO'), '2017-08-16 11:00:00', '2017-12-25 11:00:00', ), */ [ ['FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '8', 'BYDAY' => '-1MO'], '2017-08-16 11:00:00', '2017-08-28 11:00:00', ], [ ['FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1', 'BYDAY' => '1MO'], '2017-08-16 11:00:00', '2018-01-01 11:00:00', ], [ ['FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1,9', 'BYDAY' => '1MO'], '2017-08-16 11:00:00', '2017-09-04 11:00:00', ], [ ['FREQ' => 'YEARLY', 'INTERVAL' => '2'], '2017-08-16 11:00:00', '2017-08-16 11:00:00', ], [ ['FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYMONTH' => '8'], '2017-08-16 11:00:00', '2017-08-16 11:00:00', ], /* // Not supported by Sabre (requires BYMONTH too) array( array('FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYDAY' => '-1MO'), '2017-08-16 11:00:00', '2017-12-25 11:00:00', ), */ // on dates (FIXME: do we really expect the first occurrence to be on the start date?) [ ['RDATE' => [new DateTime('2017-08-10 11:00:00 Europe/Warsaw')]], '2017-08-01 11:00:00', '2017-08-01 11:00:00', ], ]; } /** * Test for libcalendaring_recurrence::end() * * @dataProvider data_end */ public function test_end($event, $expected) { $recurrence = new libcalendaring_recurrence($this->plugin, $event); $end = $recurrence->end(); $this->assertSame($expected, $end ? $end->format('Y-m-d H:i:s') : $end); } /** * Test for libcalendaring_recurrence::first_occurrence() * * @dataProvider data_first_occurrence */ public function test_first_occurrence($recurrence_data, $start, $expected) { $start = new DateTime($start); if (!empty($recurrence_data['UNTIL'])) { $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']); } $recurrence = $this->plugin->get_recurrence(); $recurrence->init($recurrence_data, $start); $first = $recurrence->first_occurrence(); $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : ''); } /** * Test for libcalendaring_recurrence::first_occurrence() for all-day events * * @dataProvider data_first_occurrence */ public function test_first_occurrence_allday($recurrence_data, $start, $expected) { $start = new libcalendaring_datetime($start); $start->_dateonly = true; if (!empty($recurrence_data['UNTIL'])) { $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']); } $recurrence = $this->plugin->get_recurrence(); $recurrence->init($recurrence_data, $start); $first = $recurrence->first_occurrence(); $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : ''); if ($expected) { $this->assertTrue($first->_dateonly); } } + /** + * Test for an event with invalid recurrence + */ + public function test_invalid_recurrence_event() + { + date_default_timezone_set('Europe/Berlin'); + + // This is an event with no RRULE, but one RDATE, however the RDATE is cancelled by EXDATE. + // This normally causes Sabre\VObject\Recur\NoInstancesException. We make sure it does not happen. + // The same will probably happen on any event without recurrence passed to libcalendring_vcalendar. + + $vcal = <<<EOF + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Apple Inc.//iCal 5.0.3//EN + CALSCALE:GREGORIAN + BEGIN:VEVENT + UID:fb1cb690-b963-4ea5-b58f-ac9773e36d9a + DTSTART;TZID=Europe/Berlin:20210604T093000 + DTEND;TZID=Europe/Berlin:20210606T093000 + RDATE:20210604T073000Z + EXDATE;TZID=Europe/Berlin:20210604T093000 + DTSTAMP:20210528T091628Z + LAST-MODIFIED:20210528T091628Z + CREATED:20210528T091213Z + END:VEVENT + END:VCALENDAR + EOF; + + $ical = new libcalendaring_vcalendar(); + $event = $ical->import($vcal)[0]; + + $recurrence = new libcalendaring_recurrence($this->plugin, $event); + + $this->assertSame($event['start']->format('Y-m-d H:i:s'), $recurrence->end()->format('Y-m-d H:i:s')); + $this->assertSame($event['start']->format('Y-m-d H:i:s'), $recurrence->first_occurrence()->format('Y-m-d H:i:s')); + $this->assertFalse($recurrence->next_start()); + $this->assertFalse($recurrence->next_instance()); + } + /** * Test for libcalendaring_recurrence::next_instance() */ public function test_next_instance() { date_default_timezone_set('America/New_York'); $start = new libcalendaring_datetime('2017-08-31 11:00:00', new DateTimeZone('Europe/Berlin')); $event = [ 'start' => $start, 'recurrence' => ['FREQ' => 'WEEKLY', 'INTERVAL' => '1'], 'allday' => true, ]; $recurrence = new libcalendaring_recurrence($this->plugin, $event); $next = $recurrence->next_instance(); $this->assertEquals($start->format('2017-09-07 H:i:s'), $next['start']->format('Y-m-d H:i:s'), 'Same time'); $this->assertEquals($start->getTimezone()->getName(), $next['start']->getTimezone()->getName(), 'Same timezone'); $this->assertTrue($next['start']->_dateonly, '_dateonly flag'); } /** * Test for libcalendaring_recurrence::next_instance() */ public function test_next_instance_exdate() { date_default_timezone_set('America/New_York'); $start = new libcalendaring_datetime('2023-01-18 10:00:00', new DateTimeZone('Europe/Berlin')); $end = new libcalendaring_datetime('2023-01-18 10:30:00', new DateTimeZone('Europe/Berlin')); $event = [ 'start' => $start, 'end' => $end, 'recurrence' => [ 'FREQ' => 'DAILY', 'INTERVAL' => '1', 'EXDATE' => [ // Exclude the start date new libcalendaring_datetime('2023-01-18 10:00:00', new DateTimeZone('Europe/Berlin')), ], ], ]; $recurrence = new libcalendaring_recurrence($this->plugin, $event); $next = $recurrence->next_instance(); $this->assertEquals('2023-01-19 10:00:00', $next['start']->format('Y-m-d H:i:s')); $this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName()); $this->assertFalse($next['start']->_dateonly); $next = $recurrence->next_instance(); $this->assertEquals('2023-01-20 10:00:00', $next['start']->format('Y-m-d H:i:s')); $this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName()); $this->assertFalse($next['start']->_dateonly); } /** * Test for libcalendaring_recurrence::next_instance() */ public function test_next_instance_dst() { date_default_timezone_set('America/New_York'); $start = new libcalendaring_datetime('2021-03-10 10:00:00', new DateTimeZone('Europe/Berlin')); $end = new libcalendaring_datetime('2021-03-10 10:30:00', new DateTimeZone('Europe/Berlin')); $event = [ 'start' => $start, 'end' => $end, 'recurrence' => [ 'FREQ' => 'MONTHLY', 'INTERVAL' => '1', ], ]; $recurrence = new libcalendaring_recurrence($this->plugin, $event); $next = $recurrence->next_instance(); $this->assertEquals('2021-04-10 10:00:00', $next['start']->format('Y-m-d H:i:s')); $this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName()); $next = $recurrence->next_instance(); $this->assertEquals('2021-05-10 10:00:00', $next['start']->format('Y-m-d H:i:s')); $this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName()); $start = new libcalendaring_datetime('2021-10-10 10:00:00', new DateTimeZone('Europe/Berlin')); $end = new libcalendaring_datetime('2021-10-10 10:30:00', new DateTimeZone('Europe/Berlin')); $event = [ 'start' => $start, 'end' => $end, 'recurrence' => [ 'FREQ' => 'MONTHLY', 'INTERVAL' => '1', ], ]; $recurrence = new libcalendaring_recurrence($this->plugin, $event); $next = $recurrence->next_instance(); $this->assertEquals('2021-11-10 10:00:00', $next['start']->format('Y-m-d H:i:s')); $this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName()); $next = $recurrence->next_instance(); $this->assertEquals('2021-12-10 10:00:00', $next['start']->format('Y-m-d H:i:s')); $this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName()); $next = $recurrence->next_instance(); $next = $recurrence->next_instance(); $next = $recurrence->next_instance(); $next = $recurrence->next_instance(); $this->assertEquals('2022-04-10 10:00:00', $next['start']->format('Y-m-d H:i:s')); $this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName()); } } diff --git a/plugins/libkolab/lib/kolab_storage_cache_event.php b/plugins/libkolab/lib/kolab_storage_cache_event.php index 8a4028f2..a7a845c7 100644 --- a/plugins/libkolab/lib/kolab_storage_cache_event.php +++ b/plugins/libkolab/lib/kolab_storage_cache_event.php @@ -1,68 +1,69 @@ <?php /** * Kolab storage cache class for calendar event objects * * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2013, Kolab Systems AG <contact@kolabsys.com> * * 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/>. */ class kolab_storage_cache_event extends kolab_storage_cache { protected $extra_cols = ['dtstart','dtend']; protected $data_props = ['categories', 'status', 'attendees']; // start, end /** * Helper method to convert the given Kolab object into a dataset to be written to cache * * @override */ protected function _serialize($object) { $sql_data = parent::_serialize($object); $sql_data['dtstart'] = $this->_convert_datetime($object['start'] ?? null); $sql_data['dtend'] = $this->_convert_datetime($object['end'] ?? null); // extend date range for recurring events if (!empty($object['recurrence'])) { $recurrence = new kolab_date_recurrence($object['_formatobj']); - $dtend = $recurrence->end() ?: new DateTime('now +100 years'); - $sql_data['dtend'] = $this->_convert_datetime($dtend); + if ($dtend = $recurrence->end()) { + $sql_data['dtend'] = $this->_convert_datetime($dtend); + } } // extend start/end dates to spawn all exceptions if (!empty($object['exceptions'])) { foreach ($object['exceptions'] as $exception) { if (($exception['start'] ?? null) instanceof DateTimeInterface) { $exstart = $this->_convert_datetime($exception['start']); if ($exstart < $sql_data['dtstart']) { $sql_data['dtstart'] = $exstart; } } if (($exception['end'] ?? null) instanceof DateTimeInterface) { $exend = $this->_convert_datetime($exception['end']); if ($exend > $sql_data['dtend']) { $sql_data['dtend'] = $exend; } } } } return $sql_data; } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache_event.php b/plugins/libkolab/lib/kolab_storage_dav_cache_event.php index 0f31b6e0..053c1573 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_cache_event.php +++ b/plugins/libkolab/lib/kolab_storage_dav_cache_event.php @@ -1,154 +1,155 @@ <?php /** * Kolab storage cache class for calendar event objects * * @author Aleksander Machniak <machniak@apheleia-it.ch> * * Copyright (C) 2013-2022 Apheleia IT AG <contact@apheleia-it.ch> * * 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/>. */ class kolab_storage_dav_cache_event extends kolab_storage_dav_cache { protected $extra_cols = ['dtstart','dtend']; protected $data_props = ['categories', 'status', 'attendees']; protected $fulltext_cols = ['title', 'description', 'location', 'attendees:name', 'attendees:email', 'categories']; /** * Helper method to convert the given Kolab object into a dataset to be written to cache * * @override */ protected function _serialize($object) { $sql_data = parent::_serialize($object); $sql_data['dtstart'] = $this->_convert_datetime($object['start']); $sql_data['dtend'] = $this->_convert_datetime($object['end']); // extend date range for recurring events if (!empty($object['recurrence'])) { $recurrence = libcalendaring::get_recurrence($object); - $dtend = $recurrence->end() ?: new DateTime('now +100 years'); - $sql_data['dtend'] = $this->_convert_datetime($dtend); + if ($dtend = $recurrence->end()) { + $sql_data['dtend'] = $this->_convert_datetime($dtend); + } } // extend start/end dates to spawn all exceptions // FIXME: This should be done via libcalendaring_recurrence use above? if (!empty($object['exceptions']) && is_array($object['exceptions'])) { foreach ($object['exceptions'] as $exception) { if ($exception['start'] instanceof DateTimeInterface) { $exstart = $this->_convert_datetime($exception['start']); if ($exstart < $sql_data['dtstart']) { $sql_data['dtstart'] = $exstart; } } if ($exception['end'] instanceof DateTimeInterface) { $exend = $this->_convert_datetime($exception['end']); if ($exend > $sql_data['dtend']) { $sql_data['dtend'] = $exend; } } } } $sql_data['tags'] = ' ' . implode(' ', $this->get_tags($object)) . ' '; // pad with spaces for strict/prefix search $sql_data['words'] = ' ' . implode(' ', $this->get_words($object)) . ' '; return $sql_data; } /** * Callback to get words to index for fulltext search * * @return array List of words to save in cache */ public function get_words($object = []) { $data = ''; foreach ($this->fulltext_cols as $colname) { [$col, $field] = strpos($colname, ':') ? explode(':', $colname) : [$colname, null]; if (empty($object[$col])) { continue; } if ($field) { $a = []; foreach ((array) $object[$col] as $attr) { if (!empty($attr[$field])) { $a[] = $attr[$field]; } } $val = implode(' ', $a); } else { $val = is_array($object[$col]) ? implode(' ', $object[$col]) : $object[$col]; } if (is_string($val) && strlen($val)) { $data .= $val . ' '; } } $words = rcube_utils::normalize_string($data, true); // collect words from recurrence exceptions if (!empty($object['exceptions']) && is_array($object['exceptions'])) { foreach ($object['exceptions'] as $exception) { $words = array_merge($words, $this->get_words($exception)); } } return array_unique($words); } /** * Callback to get object specific tags to cache * * @return array List of tags to save in cache */ public function get_tags($object) { $tags = []; if (!empty($object['valarms'])) { $tags[] = 'x-has-alarms'; } // create tags reflecting participant status if (is_array($object['attendees'])) { foreach ($object['attendees'] as $attendee) { if (!empty($attendee['email']) && !empty($attendee['status'])) { $tags[] = 'x-partstat:' . $attendee['email'] . ':' . strtolower($attendee['status']); } } } // collect tags from recurrence exceptions if (!empty($object['exceptions']) && is_array($object['exceptions'])) { foreach ($object['exceptions'] as $exception) { $tags = array_merge($tags, $this->get_tags($exception)); } } if (!empty($object['status'])) { $tags[] = 'x-status:' . strtolower($object['status']); } return array_unique($tags); } }