diff --git a/lib/kolab_sync_timezone_converter.php b/lib/kolab_sync_timezone_converter.php index 01c2a11..91fcdf0 100644 --- a/lib/kolab_sync_timezone_converter.php +++ b/lib/kolab_sync_timezone_converter.php @@ -1,656 +1,649 @@ <?php /* +--------------------------------------------------------------------------+ | Kolab Sync (ActiveSync for Kolab) | | | | Copyright (C) 2011-2017, Kolab Systems AG <contact@kolabsys.com> | | Copyright (C) 2008-2012, Metaways Infosystems GmbH | | | | 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/> | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak <machniak@kolabsys.com> | | Author: Jonas Fischer <j.fischer@metaways.de> | +--------------------------------------------------------------------------+ */ /** * Activesync timezone converter */ class kolab_sync_timezone_converter { /** * holds the instance of the singleton * * @var ?kolab_sync_timezone_converter */ private static $_instance; protected $_startDate = []; - /** - * If set then the timezone guessing results will be cached. - * This is strongly recommended for performance reasons. - * - * @var rcube_cache - */ - protected $cache = null; - /** * array of offsets known by ActiceSync clients, but unknown by php + * * @var array */ protected $_knownTimezones = [ '0AIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==' => [ 'Pacific/Kwajalein' => 'MHT', ], ]; protected $_legacyTimezones = [ // This is an outdated timezone that outlook keeps sending because of an outdate timezone database on windows 'Lv///0kAcgBhAG4AIABTAHQAYQBuAGQAYQByAGQAIABUAGkAbQBlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkABAADABcAOwA7AOcDAAAAAEkAcgBhAG4AIABEAGEAeQBsAGkAZwBoAHQAIABUAGkAbQBlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAwAEAAAAAAAAAAAAxP///w==' => [ 'Asia/Tehran' => '+0330', ], ]; /** * don't use the constructor. Use the singleton. * * @param $_logger */ private function __construct() { } /** * don't clone. Use the singleton. */ private function __clone() { } /** * the singleton pattern * * @return kolab_sync_timezone_converter */ public static function getInstance() { if (self::$_instance === null) { self::$_instance = new kolab_sync_timezone_converter(); } return self::$_instance; } /** * Returns a timezone with an offset matching the time difference * of $dt from $referenceDt. * * If set and matching the offset, kolab_format::$timezone is preferred. * * @param DateTime $dt The date time value for which we * calculate the offset. * @param DateTime $referenceDt The reference value, for instance in UTC. * * @return DateTimeZone|null */ public function getOffsetTimezone($dt, $referenceDt) { $interval = $referenceDt->diff($dt); $tz = new DateTimeZone($interval->format('%R%H%I')); //e.g. +0200 $utcOffset = $tz->getOffset($dt); //Prefer the configured timezone if it matches the offset. if (kolab_format::$timezone) { if (kolab_format::$timezone->getOffset($dt) == $utcOffset) { return kolab_format::$timezone; } } //Look for any timezone with a matching offset. foreach (DateTimeZone::listIdentifiers() as $timezoneIdentifier) { $timezone = new DateTimeZone($timezoneIdentifier); if ($timezone->getOffset($dt) == $utcOffset) { return $timezone; } } return null; } /** * Returns a list of timezones that match to the {@param $_offsets} * * If {@see $_expectedTimezone} is set then the method will terminate as soon * as the expected timezone has matched and the expected timezone will be the * first entry to the returned array. * * @param string|array $_offsets * * @return array */ public function getListOfTimezones($_offsets) { if (is_string($_offsets) && isset($this->_knownTimezones[$_offsets])) { $timezones = $this->_knownTimezones[$_offsets]; } elseif (is_string($_offsets) && isset($this->_legacyTimezones[$_offsets])) { $timezones = $this->_legacyTimezones[$_offsets]; } else { if (is_string($_offsets)) { // unpack timezone info to array $_offsets = $this->_unpackTimezoneInfo($_offsets); } if (!$this->_validateOffsets($_offsets)) { return []; } $this->_setDefaultStartDateIfEmpty($_offsets); $timezones = []; foreach (DateTimeZone::listIdentifiers() as $timezoneIdentifier) { $timezone = new DateTimeZone($timezoneIdentifier); if (false !== ($matchingTransition = $this->_checkTimezone($timezone, $_offsets))) { $timezones[$timezoneIdentifier] = $matchingTransition['abbr']; } } } return $timezones; } /** * Returns PHP timezone that matches to the {@param $_offsets} * * If {@see $_expectedTimezone} is set then the method will return this timezone if it matches. * * @param string|array $_offsets Activesync timezone definition * @param string $_expectedTimezone Expected timezone name * * @return string Expected timezone name */ public function getTimezone($_offsets, $_expectedTimezone = null) { $timezones = $this->getListOfTimezones($_offsets); if ($_expectedTimezone && isset($timezones[$_expectedTimezone])) { return $_expectedTimezone; } else { return key($timezones); } } /** * Return packed string for given {@param $_timezone} * * @param string $_timezone Timezone identifier * @param string|int $_startDate Start date * * @return string Packed timezone offsets */ public function encodeTimezone($_timezone, $_startDate = null) { foreach ($this->_knownTimezones as $packedString => $knownTimezone) { if (array_key_exists($_timezone, $knownTimezone)) { return $packedString; } } $offsets = $this->getOffsetsForTimezone($_timezone, $_startDate); return $this->_packTimezoneInfo($offsets); } /** * Returns an encoded timezone representation from $date * * @param DateTime $date The date with the timezone to encode * * @return string|null Timezone name */ public static function encodeTimezoneFromDate($date) { if ($date instanceof DateTime) { $timezone = $date->getTimezone(); if (($tz_name = $timezone->getName()) != 'UTC') { $tzc = self::getInstance(); if ($tz_name = $tzc->encodeTimezone($tz_name, $date->format('Y-m-d'))) { return $tz_name; } } } return null; } /** * Get offsets for given timezone * * @param string $_timezone Timezone identifier * @param string|int $_startDate Start date * * @return array|null Timezone offsets */ public function getOffsetsForTimezone($_timezone, $_startDate = null) { $this->_setStartDate($_startDate); $offsets = $this->_getOffsetsTemplate(); try { $timezone = new DateTimeZone($_timezone); } catch (Exception $e) { return null; } [$standardTransition, $daylightTransition] = $this->_getTransitionsForTimezoneAndYear($timezone, $this->_startDate['year']); if ($standardTransition) { $offsets['bias'] = $standardTransition['offset'] / 60 * -1; if ($daylightTransition) { $offsets = $this->_generateOffsetsForTransition($offsets, $standardTransition, 'standard', $timezone); $offsets = $this->_generateOffsetsForTransition($offsets, $daylightTransition, 'daylight', $timezone); //@todo how do we get the standardBias (is usually 0)? //$offsets['standardBias'] = ... $offsets['daylightBias'] = ($daylightTransition['offset'] - $standardTransition['offset']) / 60 * -1; $offsets['standardHour'] -= $offsets['daylightBias'] / 60; $offsets['daylightHour'] += $offsets['daylightBias'] / 60; } } return $offsets; } /** * Get offsets for timezone transition * * @param array $_offsets Timezone offsets * @param array $_transition Timezone transition information * @param string $_type Transition type: 'standard' or 'daylight' * @param DateTimeZone $_timezone Timezone of the transition * * @return array */ protected function _generateOffsetsForTransition(array $_offsets, array $_transition, $_type, $_timezone) { $transitionDate = new DateTime($_transition['time'], $_timezone); if ($_transition['offset']) { $transitionDate->modify($_transition['offset'] . ' seconds'); } $_offsets[$_type . 'Month'] = (int) $transitionDate->format('n'); $_offsets[$_type . 'DayOfWeek'] = (int) $transitionDate->format('w'); $_offsets[$_type . 'Minute'] = (int) $transitionDate->format('i'); $_offsets[$_type . 'Hour'] = (int) $transitionDate->format('G'); for ($i = 5; $i > 0; $i--) { if ($this->_isNthOcurrenceOfWeekdayInMonth($transitionDate, $i)) { $_offsets[$_type . 'Week'] = $i; break; }; } return $_offsets; } /** * Test if the weekday of the given {@param $_timestamp} is the {@param $_occurence}th occurence of this weekday within its month. * * @param DateTime $_datetime * @param int $_occurence [1 to 5, where 5 indicates the final occurrence during the month if that day of the week does not occur 5 times] * * @return bool */ protected function _isNthOcurrenceOfWeekdayInMonth($_datetime, $_occurence) { if ($_occurence <= 1) { return true; } $orig = $_datetime->format('n'); if ($_occurence == 5) { $modified = clone($_datetime); $modified->modify('1 week'); $mod = $modified->format('n'); // modified date is a next month return $mod > $orig || ($mod == 1 && $orig == 12); } $modified = clone($_datetime); $modified->modify(sprintf('-%d weeks', $_occurence - 1)); $mod = $modified->format('n'); if ($mod != $orig) { return false; } $modified = clone($_datetime); $modified->modify(sprintf('-%d weeks', $_occurence)); $mod = $modified->format('n'); // modified month is earlier than original return $mod < $orig || ($mod == 12 && $orig == 1); } /** * Check if the given {@param $_standardTransition} and {@param $_daylightTransition} * match to the object property {@see $_offsets} * * @param array $_standardTransition * @param array $_daylightTransition * @param array $_offsets * @param DateTimeZone $tz * * @return bool */ protected function _checkTransition($_standardTransition, $_daylightTransition, $_offsets, $tz) { if (empty($_standardTransition) || empty($_offsets)) { return false; } $standardOffset = ($_offsets['bias'] + $_offsets['standardBias']) * 60 * -1; // check each condition in a single if statement and break the chain when one condition is not met - for performance reasons if ($standardOffset == $_standardTransition['offset']) { if (empty($_offsets['daylightMonth']) && (empty($_daylightTransition) || empty($_daylightTransition['isdst']))) { // No DST return true; } $daylightOffset = ($_offsets['bias'] + $_offsets['daylightBias']) * 60 * -1; // the milestone is sending a positive value for daylightBias while it should send a negative value $daylightOffsetMilestone = ($_offsets['bias'] + ($_offsets['daylightBias'] * -1)) * 60 * -1; if ( !empty($_daylightTransition) && ($daylightOffset == $_daylightTransition['offset'] || $daylightOffsetMilestone == $_daylightTransition['offset']) ) { // date-time input here contains UTC timezone specifier (+0000), // we have to convert the date to the requested timezone afterwards. $standardDate = new DateTime($_standardTransition['time']); $daylightDate = new DateTime($_daylightTransition['time']); $standardDate->setTimezone($tz); $daylightDate->setTimezone($tz); if ($standardDate->format('n') == $_offsets['standardMonth'] && $daylightDate->format('n') == $_offsets['daylightMonth'] && $standardDate->format('w') == $_offsets['standardDayOfWeek'] && $daylightDate->format('w') == $_offsets['daylightDayOfWeek'] ) { return $this->_isNthOcurrenceOfWeekdayInMonth($daylightDate, $_offsets['daylightWeek']) && $this->_isNthOcurrenceOfWeekdayInMonth($standardDate, $_offsets['standardWeek']); } } } return false; } /** * decode timezone info from activesync * * @param string $_packedTimezoneInfo the packed timezone info * @return array */ protected function _unpackTimezoneInfo($_packedTimezoneInfo) { $timezoneUnpackString = 'lbias/a64standardName/vstandardYear/vstandardMonth/vstandardDayOfWeek/vstandardWeek/vstandardHour/vstandardMinute/vstandardSecond/vstandardMilliseconds/lstandardBias' . '/a64daylightName/vdaylightYear/vdaylightMonth/vdaylightDayOfWeek/vdaylightWeek/vdaylightHour/vdaylightMinute/vdaylightSecond/vdaylightMilliseconds/ldaylightBias'; $timezoneInfo = unpack($timezoneUnpackString, base64_decode($_packedTimezoneInfo)); if ($timezoneInfo['standardHour'] == 23 && $timezoneInfo['standardMilliseconds'] == 999 && $timezoneInfo['standardMinute'] == 59 && $timezoneInfo['standardSecond'] == 59 ) { $timezoneInfo['standardHour'] = 24; $timezoneInfo['standardMinute'] = 0; $timezoneInfo['standardSecond'] = 0; $timezoneInfo['standardMilliseconds'] = 0; } return $timezoneInfo; } /** * Encode timezone info to activesync * * @param array $_timezoneInfo * * @return string|null */ protected function _packTimezoneInfo($_timezoneInfo) { if (!is_array($_timezoneInfo)) { return null; } // According to e.g. https://docs.microsoft.com/en-us/windows/win32/api/minwinbase/ns-minwinbase-systemtime, // 24 is not allowed in the Hour field, and consequently Outlook can't deal with it. // This is the same workaround that Outlook applies. if ($_timezoneInfo['standardHour'] == 24) { $_timezoneInfo['standardHour'] = 23; $_timezoneInfo['standardMinute'] = 59; $_timezoneInfo['standardSecond'] = 59; $_timezoneInfo['standardMilliseconds'] = 999; } $packed = pack( "la64vvvvvvvvla64vvvvvvvvl", $_timezoneInfo['bias'], $_timezoneInfo['standardName'], $_timezoneInfo['standardYear'], $_timezoneInfo['standardMonth'], $_timezoneInfo['standardDayOfWeek'], $_timezoneInfo['standardWeek'], $_timezoneInfo['standardHour'], $_timezoneInfo['standardMinute'], $_timezoneInfo['standardSecond'], $_timezoneInfo['standardMilliseconds'], $_timezoneInfo['standardBias'], $_timezoneInfo['daylightName'], $_timezoneInfo['daylightYear'], $_timezoneInfo['daylightMonth'], $_timezoneInfo['daylightDayOfWeek'], $_timezoneInfo['daylightWeek'], $_timezoneInfo['daylightHour'], $_timezoneInfo['daylightMinute'], $_timezoneInfo['daylightSecond'], $_timezoneInfo['daylightMilliseconds'], $_timezoneInfo['daylightBias'] ); return base64_encode($packed); } /** * Returns complete offsets array with all fields empty * * Used e.g. when reverse-generating ActiveSync Timezone Offset Information * based on a given Timezone, {@see getOffsetsForTimezone} * * @return array */ protected function _getOffsetsTemplate() { return [ 'bias' => 0, 'standardName' => '', 'standardYear' => 0, 'standardMonth' => 0, 'standardDayOfWeek' => 0, 'standardWeek' => 0, 'standardHour' => 0, 'standardMinute' => 0, 'standardSecond' => 0, 'standardMilliseconds' => 0, 'standardBias' => 0, 'daylightName' => '', 'daylightYear' => 0, 'daylightMonth' => 0, 'daylightDayOfWeek' => 0, 'daylightWeek' => 0, 'daylightHour' => 0, 'daylightMinute' => 0, 'daylightSecond' => 0, 'daylightMilliseconds' => 0, 'daylightBias' => 0, ]; } /** * Validate and set offsets * * @param array $value * * @return bool Validation result */ protected function _validateOffsets($value) { // validate $value if ((!empty($value['standardMonth']) || !empty($value['standardWeek']) || !empty($value['daylightMonth']) || !empty($value['daylightWeek'])) && (empty($value['standardMonth']) || empty($value['standardWeek']) || empty($value['daylightMonth']) || empty($value['daylightWeek'])) ) { // It is not possible not set standard offsets without setting daylight offsets and vice versa return false; } return true; } /** * Parse and set object property {@see $_startDate} * * @param mixed $_startDate * @return void */ protected function _setStartDate($_startDate) { if (empty($_startDate)) { $this->_setDefaultStartDateIfEmpty(); return; } $startDateParsed = []; if (is_string($_startDate)) { $startDateParsed['string'] = $_startDate; $startDateParsed['ts'] = strtotime($_startDate); } elseif (is_int($_startDate)) { $startDateParsed['ts'] = $_startDate; $startDateParsed['string'] = date('Y-m-d', $_startDate); } else { $this->_setDefaultStartDateIfEmpty(); return; } $startDateParsed['object'] = new DateTime($startDateParsed['string']); $startDateParsed = array_merge($startDateParsed, getdate($startDateParsed['ts'])); $this->_startDate = $startDateParsed; } /** * Set default value for object property {@see $_startdate} if it is not set yet. * Tries to guess the correct startDate depending on object property {@see $_offsets} and * falls back to current date. * * @param array $_offsets [offsets may be avaluated for a given start year] * @return void */ protected function _setDefaultStartDateIfEmpty($_offsets = null) { if (!empty($this->_startDate)) { return; } if (!empty($_offsets['standardYear'])) { $this->_setStartDate($_offsets['standardYear'] . '-01-01'); } else { $this->_setStartDate(time()); } } /** * Check if the given {@param $_timezone} matches the {@see $_offsets} * and also evaluate the daylight saving time transitions for this timezone if necessary. * * @param DateTimeZone $timezone * @param array $offsets * * @return array|bool */ protected function _checkTimezone(DateTimeZone $timezone, $offsets) { [$standardTransition, $daylightTransition] = $this->_getTransitionsForTimezoneAndYear($timezone, $this->_startDate['year']); if ($this->_checkTransition($standardTransition, $daylightTransition, $offsets, $timezone)) { return $standardTransition; } return false; } /** * Returns the standard and daylight transitions for the given {@param $_timezone} * and {@param $_year}. * * @param DateTimeZone $_timezone * @param int $_year * * @return array */ protected function _getTransitionsForTimezoneAndYear(DateTimeZone $_timezone, $_year) { $standardTransition = null; $daylightTransition = null; $start = mktime(0, 0, 0, 12, 1, $_year - 1); $end = mktime(24, 0, 0, 12, 31, $_year); $transitions = $_timezone->getTransitions($start, $end); if ($transitions === false) { return [null, null]; } foreach ($transitions as $index => $transition) { if (date('Y', $transition['ts']) == $_year) { if (isset($transitions[$index + 1]) && date('Y', $transitions[$index]['ts']) == date('Y', $transitions[$index + 1]['ts'])) { $daylightTransition = $transition['isdst'] ? $transition : $transitions[$index + 1]; $standardTransition = $transition['isdst'] ? $transitions[$index + 1] : $transition; } else { $daylightTransition = $transition['isdst'] ? $transition : null; $standardTransition = $transition['isdst'] ? null : $transition; } break; } elseif ($index == count($transitions) - 1) { $standardTransition = $transition; } } return [$standardTransition, $daylightTransition]; } }