diff --git a/application/menunode.class.inc.php b/application/menunode.class.inc.php index 83c847175..9d04125ea 100644 --- a/application/menunode.class.inc.php +++ b/application/menunode.class.inc.php @@ -1123,7 +1123,7 @@ class OQLMenuNode extends MenuNode */ public function RenderContent(WebPage $oPage, $aExtraParams = array()) { - ContextTag::AddContext(ContextTag::TAG_OBJECT_SEARCH); + $oTag = new ContextTag(ContextTag::TAG_OBJECT_SEARCH); ApplicationMenu::CheckMenuIdEnabled($this->GetMenuId()); OQLMenuNode::RenderOQLSearch ( diff --git a/core/config.class.inc.php b/core/config.class.inc.php index db78a8aa8..1ac68fa1b 100644 --- a/core/config.class.inc.php +++ b/core/config.class.inc.php @@ -1193,6 +1193,30 @@ class Config 'source_of_value' => '', 'show_in_conf_sample' => false, ], + 'sessions_tracking.enabled' => [ + 'type' => 'bool', + 'description' => 'Whether or not the whole mechanism to track active sessions is enabled. See PHP session.gc_maxlifetime setting to configure session expiration.', + 'default' => false, + 'value' => '', + 'source_of_value' => '', + 'show_in_conf_sample' => false, + ], + 'sessions_tracking.gc_threshold' => [ + 'type' => 'integer', + 'description' => 'fallback in case cron is not active: probability in percent that session files are cleanup during any itop request (100 means always)', + 'default' => 1, + 'value' => '', + 'source_of_value' => '', + 'show_in_conf_sample' => false, + ], + 'sessions_tracking.gc_duration_in_seconds' => [ + 'type' => 'integer', + 'description' => 'fallback in case cron is not active: when a cleanup is triggered cleanup duration will not exceed this duration (in seconds).', + 'default' => 1, + 'value' => '', + 'source_of_value' => '', + 'show_in_conf_sample' => false, + ], 'transaction_storage' => [ 'type' => 'string', 'description' => 'The type of mechanism to use for storing the unique identifiers for transactions (Session|File).', diff --git a/core/log.class.inc.php b/core/log.class.inc.php index 8f59e7e4c..dac307812 100644 --- a/core/log.class.inc.php +++ b/core/log.class.inc.php @@ -3,7 +3,7 @@ // // This file is part of iTop. // -// iTop is free software; you can redistribute it and/or modify +// iTop 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. @@ -576,6 +576,11 @@ class LogChannels public const DATATABLE = 'Datatable'; public const DEADLOCK = 'DeadLock'; + /** + * @var string Everything related to PHP sessions tracking + * @since 3.1.1 3.2.0 N°6901 + */ + public const SESSIONTRACKER = 'SessionTracker'; /** * @var string Everything related to the datamodel CRUD diff --git a/lib/composer/autoload_classmap.php b/lib/composer/autoload_classmap.php index b95541401..42e23125c 100644 --- a/lib/composer/autoload_classmap.php +++ b/lib/composer/autoload_classmap.php @@ -476,6 +476,8 @@ return array( 'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectManager' => $baseDir . '/sources/Service/TemporaryObjects/TemporaryObjectManager.php', 'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectRepository' => $baseDir . '/sources/Service/TemporaryObjects/TemporaryObjectRepository.php', 'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectsEvents' => $baseDir . '/sources/Service/TemporaryObjects/TemporaryObjectsEvents.php', + 'Combodo\\iTop\\SessionTracker\\SessionGC' => $baseDir . '/sources/SessionTracker/SessionGC.php', + 'Combodo\\iTop\\SessionTracker\\SessionHandler' => $baseDir . '/sources/SessionTracker/SessionHandler.php', 'CompileCSSService' => $baseDir . '/application/compilecssservice.class.inc.php', 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', 'Config' => $baseDir . '/core/config.class.inc.php', diff --git a/lib/composer/autoload_static.php b/lib/composer/autoload_static.php index e3661ab24..97bcfc1b6 100644 --- a/lib/composer/autoload_static.php +++ b/lib/composer/autoload_static.php @@ -840,6 +840,8 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f 'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectManager' => __DIR__ . '/../..' . '/sources/Service/TemporaryObjects/TemporaryObjectManager.php', 'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectRepository' => __DIR__ . '/../..' . '/sources/Service/TemporaryObjects/TemporaryObjectRepository.php', 'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectsEvents' => __DIR__ . '/../..' . '/sources/Service/TemporaryObjects/TemporaryObjectsEvents.php', + 'Combodo\\iTop\\SessionTracker\\SessionGC' => __DIR__ . '/../..' . '/sources/SessionTracker/SessionGC.php', + 'Combodo\\iTop\\SessionTracker\\SessionHandler' => __DIR__ . '/../..' . '/sources/SessionTracker/SessionHandler.php', 'CompileCSSService' => __DIR__ . '/../..' . '/application/compilecssservice.class.inc.php', 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', 'Config' => __DIR__ . '/../..' . '/core/config.class.inc.php', diff --git a/pages/ajax.render.php b/pages/ajax.render.php index 08b67a9c3..d6f3feebe 100644 --- a/pages/ajax.render.php +++ b/pages/ajax.render.php @@ -69,7 +69,7 @@ try break; default: - ContextTag::AddContext(ContextTag::TAG_CONSOLE); + $oTag = new ContextTag(ContextTag::TAG_CONSOLE); } // First check if we can redirect the route to a dedicated controller diff --git a/sources/Application/Helper/Session.php b/sources/Application/Helper/Session.php index 4b708a61e..867e3ca70 100644 --- a/sources/Application/Helper/Session.php +++ b/sources/Application/Helper/Session.php @@ -7,6 +7,7 @@ namespace Combodo\iTop\Application\Helper; +use Combodo\iTop\SessionTracker\SessionHandler; use utils; /** @@ -34,6 +35,7 @@ class Session } if (!self::$bIsInitialized) { + SessionHandler::session_set_save_handler(); session_name('itop-'.md5(APPROOT)); } @@ -214,4 +216,4 @@ class Session return utils::IsModeCLI(); } -} \ No newline at end of file +} diff --git a/sources/SessionTracker/SessionGC.php b/sources/SessionTracker/SessionGC.php new file mode 100644 index 000000000..8653ea26d --- /dev/null +++ b/sources/SessionTracker/SessionGC.php @@ -0,0 +1,32 @@ + + * @package Combodo\iTop\SessionTracker + * @since 3.1.1 3.2.0 N°6901 + */ +class SessionGC implements \iBackgroundProcess +{ + /** + * @inheritDoc + */ + public function GetPeriodicity() + { + return 60 * 1; // seconds + } + + /** + * @inheritDoc + */ + public function Process($iTimeLimit) + { + $iMaxLifetime = ini_get('session.gc_maxlifetime') ?? 1440; + $oSessionHandler = new SessionHandler(); + $iProcessed = $oSessionHandler->gc_with_time_limit($iMaxLifetime, $iTimeLimit); + return "processed $iProcessed tasks"; + } +} diff --git a/sources/SessionTracker/SessionHandler.php b/sources/SessionTracker/SessionHandler.php new file mode 100644 index 000000000..5aa0c4072 --- /dev/null +++ b/sources/SessionTracker/SessionHandler.php @@ -0,0 +1,240 @@ + + * @package Combodo\iTop\SessionTracker + * @since 3.1.1 3.2.0 N°6901 + */ +class SessionHandler extends \SessionHandler +{ + /** + * @param string $session_id + * + * @return bool + */ + #[ReturnTypeWillChange] + public function destroy($session_id) : bool + { + IssueLog::Debug("Destroy PHP session", \LogChannels::SESSIONTRACKER, [ + 'session_id' => $session_id, + ]); + $bRes = parent::destroy($session_id); + + if ($bRes) { + $this->unlink_session_file($session_id); + } + + return $bRes; + } + + /** + * @param int $max_lifetime + */ + #[ReturnTypeWillChange] + public function gc($max_lifetime) : bool + { + IssueLog::Debug("Run PHP sessions garbage collector", \LogChannels::SESSIONTRACKER, [ + 'max_lifetime' => $max_lifetime, + ]); + $iRes = parent::gc($max_lifetime); + $this->gc_with_time_limit($max_lifetime); + return $iRes; + } + + /** + * @param string $save_path + * @param string $session_name + */ + #[ReturnTypeWillChange] + public function open($save_path, $session_name) : bool + { + $bRes = parent::open($save_path, $session_name); + + $session_id = session_id(); + IssueLog::Debug("Open PHP session", \LogChannels::SESSIONTRACKER, [ + 'session_id' => $session_id, + ]); + + if ($bRes) { + $this->touch_session_file($session_id); + } + + return $bRes; + } + + /** + * @param string $session_id + * @param string $data + * + * @return bool + */ + #[ReturnTypeWillChange] + public function write($session_id, $data) : bool + { + $bRes = parent::write($session_id, $data); + + IssueLog::Debug("Write PHP session", \LogChannels::SESSIONTRACKER, [ + 'session_id' => $session_id, + 'data' => $data, + ]); + + if ($bRes) { + $this->touch_session_file($session_id); + } + + return $bRes; + } + + public static function session_set_save_handler() : void + { + if (false === utils::GetConfig()->Get('sessions_tracking.enabled')){ + //feature disabled + return; + } + + $sessionhandler = new SessionHandler(); + session_set_save_handler($sessionhandler, true); + + $iThreshold = utils::GetConfig()->Get('sessions_tracking.gc_threshold'); + $iThreshold = min(100, $iThreshold); + $iThreshold = max(1, $iThreshold); + if ((100 != $iThreshold) && (rand(1, 100) > $iThreshold)) { + return; + } + + $iMaxLifetime = ini_get('session.gc_maxlifetime') ?? 60; + $iMaxDurationInSeconds = utils::GetConfig()->Get('sessions_tracking.gc_duration_in_seconds'); + $sessionhandler->gc_with_time_limit($iMaxLifetime, time() + $iMaxDurationInSeconds); + } + + private function generate_session_content(?string $sPreviousFileVersionContent) : ?string + { + try { + $sUserId = UserRights::GetUserId(); + if (null === $sUserId) { + return null; + } + + // Default value in case of + // - First time file creation + // - Data corruption (not a json / not an array / no previous creation_time key) + $iCreationTime = time(); + + if (! is_null($sPreviousFileVersionContent)) { + $aJson = json_decode($sPreviousFileVersionContent, true); + if (is_array($aJson) && array_key_exists('creation_time', $aJson)) { + $iCreationTime = $aJson['creation_time']; + } + } + + return json_encode ( + [ + 'login_mode' => Session::Get('login_mode'), + 'user_id' => $sUserId, + 'creation_time' => $iCreationTime, + 'context' => implode('|', ContextTag::GetStack()) + ] + ); + } catch(Exception $e) { + + } + + return null; + } + + private function get_file_path($session_id) : string + { + return utils::GetDataPath() . "sessions/session_$session_id"; + } + + private function touch_session_file($session_id) : ?string + { + if (strlen($session_id) == 0) { + return null; + } + + clearstatcache(); + if (! is_dir(utils::GetDataPath() . "sessions")) { + @mkdir(utils::GetDataPath() . "sessions"); + } + + $sFilePath = $this->get_file_path($session_id); + + $sPreviousFileVersionContent = null; + if (is_file($sFilePath)) { + $sPreviousFileVersionContent = file_get_contents($sFilePath); + } + $sNewContent = $this->generate_session_content($sPreviousFileVersionContent); + if (is_null($sNewContent) || ($sPreviousFileVersionContent === $sNewContent)) { + @touch($sFilePath); + } else { + file_put_contents($sFilePath, $sNewContent); + } + + return $sFilePath; + } + + private function unlink_session_file($session_id) + { + $sFilePath = $this->get_file_path($session_id); + if (is_file($sFilePath)) { + @unlink($sFilePath); + } + } + + /** + * @param int $max_lifetime + * @param int $iTimeLimit Unix timestamp of time limit not to exceed. -1 for no limit. + * + * @return int + */ + public function gc_with_time_limit(int $max_lifetime, int $iTimeLimit = -1) : int + { + $aFiles = $this->list_session_files(); + $iProcessed = 0; + $now = time(); + + foreach ($aFiles as $sFile) { + if ($now - filemtime($sFile) > $max_lifetime) { + @unlink($sFile); + $iProcessed++; + } + + if (-1 !== $iTimeLimit && time() > $iTimeLimit) { + //cleanup processing has to stop after $iTimeLimit + break; + } + } + + return $iProcessed; + } + + public function list_session_files() : array + { + clearstatcache(); + if (! is_dir(utils::GetDataPath() . "sessions")) { + @mkdir(utils::GetDataPath() . "sessions"); + } + + return glob(utils::GetDataPath() . "sessions/session_*"); + } +} diff --git a/tests/php-unit-tests/unitary-tests/sources/SessionTracker/SessionHandlerTest.php b/tests/php-unit-tests/unitary-tests/sources/SessionTracker/SessionHandlerTest.php new file mode 100644 index 000000000..ac89a8421 --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/sources/SessionTracker/SessionHandlerTest.php @@ -0,0 +1,241 @@ +aFiles=[]; + $this->oTag = new ContextTag(ContextTag::TAG_REST); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->oTag = null; + + foreach ($this->aFiles as $sFile){ + if (is_file($sFile)){ + @unlink($sFile); + } + } + } + + private function CreateUserAndLogIn() : ? string { + $_SESSION = []; + $oUser = $this->CreateContactlessUser("admin" . uniqid(), 1, "1234@Abcdefg"); + + \UserRights::Login($oUser->Get('login')); + return $oUser->GetKey(); + } + + private function GenerateSessionContent(SessionHandler $oSessionHandler, ?string $sPreviousFileVersionContent) : ?string { + return $this->InvokeNonPublicMethod(SessionHandler::class, "generate_session_content", $oSessionHandler, $aArgs = [$sPreviousFileVersionContent]); + } + + /* + * @covers SessionHandler::generate_session_content + */ + public function testGenerateSessionContentNoUserLoggedIn(){ + $oSessionHandler = new SessionHandler(); + $sContent = $this->GenerateSessionContent($oSessionHandler, null); + $this->assertNull($sContent, "Session content should be null when there is no user logged in"); + } + + public function GenerateSessionContentCorruptedPreviousFileContentProvider() { + return [ + 'not a json' => [ "not a json" ], + 'not an array' => [ json_encode("not an array") ], + 'array without creation_time key' => [ json_encode([]) ], + ]; + } + + /** + * @covers SessionHandler::generate_session_content + * @dataProvider GenerateSessionContentCorruptedPreviousFileContentProvider + */ + public function testGenerateSessionContent_SessionFileRepairment(?string $sFileContent){ + $sUserId = $this->CreateUserAndLogIn(); + + $oSessionHandler = new SessionHandler(); + Session::Set('login_mode', 'foo_login_mode'); + + $sContent = $this->GenerateSessionContent($oSessionHandler, $sFileContent); + + $this->assertNotNull($sContent, 'Should not return null'); + $aJson = json_decode($sContent, true); + $this->assertNotEquals(false, $aJson, 'Should return a valid json string, found: '.$sContent); + $this->assertEquals($sUserId, $aJson['user_id'] ?? '', "Should report the login of the logged in user in [user_id]: $sContent"); + $this->assertEquals(ContextTag::TAG_REST, $aJson['context'] ?? '', "Should report the context tag(s) in [context]: $sContent"); + $this->assertIsInt($aJson['creation_time'] ?? '', "Should report the session start timestamp in [creation_time]: $sContent"); + $this->assertEquals('foo_login_mode', $aJson['login_mode'] ?? '', "Should report the current login mode in [login_mode]: $sContent"); + } + + /* + * @covers SessionHandler::generate_session_content + */ + public function testGenerateSessionContent(){ + $sUserId = $this->CreateUserAndLogIn(); + + $oSessionHandler = new SessionHandler(); + Session::Set('login_mode', 'foo_login_mode'); + + //first time + $sFirstContent = $this->GenerateSessionContent($oSessionHandler, null); + + $this->assertNotNull($sFirstContent, 'Should not return null'); + $aJson = json_decode($sFirstContent, true); + $this->assertNotEquals(false, $aJson, 'Should return a valid json string, found: '.$sFirstContent); + $this->assertEquals($sUserId, $aJson['user_id'] ?? '', "Should report the login of the logged in user in [user_id]: $sFirstContent"); + $this->assertEquals(ContextTag::TAG_REST, $aJson['context'] ?? '', "Should report the context tag(s) in [context]: $sFirstContent"); + $this->assertIsInt($aJson['creation_time'] ?? '', "Should report the session start timestamp in [creation_time]: $sFirstContent"); + $this->assertEquals('foo_login_mode', $aJson['login_mode'] ?? '', "Should report the current login mode in [login_mode]: $sFirstContent"); + + $iFirstSessionCreationTime = $aJson['creation_time']; + + // Switch context + change user id via impersonation + // check it is still tracked in session files + $oOtherUser = $this->CreateContactlessUser("admin" . uniqid(), 1, "1234@Abcdefg"); + $this->assertTrue(\UserRights::Impersonate($oOtherUser->Get('login')), "Failed to execute impersonate on: ".$oOtherUser->Get('login')); + $oTag2 = new ContextTag(ContextTag::TAG_SYNCHRO); + $sNewContent = $this->GenerateSessionContent($oSessionHandler, $sFirstContent); + $this->assertNotNull($sNewContent, 'Should not return null'); + $aJson = json_decode($sNewContent, true); + $this->assertNotEquals(false, $aJson, 'Should return a valid json string, found: '.$sNewContent); + $this->assertEquals(ContextTag::TAG_REST . '|' . ContextTag::TAG_SYNCHRO, $aJson['context'] ?? '', "After impersonation, should report the new context tags in [context]: $sNewContent"); + $this->assertEquals($iFirstSessionCreationTime, $aJson['creation_time'] ?? '', "After impersonation, should still report the the session start timestamp in [creation_time]: $sNewContent"); + $this->assertEquals('foo_login_mode', $aJson['login_mode'] ?? '', "After impersonation, should still report the login mode in [login_mode]: $sNewContent"); + $this->assertEquals($oOtherUser->GetKey(), $aJson['user_id'] ?? '', "Should report the impersonate user in [user_id]: $sNewContent"); + } + + private function touchSessionFile(SessionHandler $oSessionHandler, $session_id) : ?string { + $sRes = $this->InvokeNonPublicMethod(SessionHandler::class, "touch_session_file", $oSessionHandler, $aArgs = [$session_id]); + if (!is_null($sRes) && is_file($sRes)) { + // Record the file for cleanup on tearDown + $this->aFiles[] = $sRes; + } + clearstatcache(); + return $sRes; + } + + /* + * @covers SessionHandler::touch_session_file + */ + public function testTouchSessionFile_NoUserLoggedIn(){ + $oSessionHandler = new SessionHandler(); + $session_id = uniqid(); + $sFile = $this->touchSessionFile($oSessionHandler, $session_id); + $this->assertEquals(true, is_file($sFile), "Should return a file name: '$sFile' is not a valid file name"); + $sContent = file_get_contents($sFile); + $this->assertEquals(null, $sContent, 'Should create an empty file, found: '.$sContent); + } + + /* + * @covers SessionHandler::touch_session_file + */ + public function testTouchSessionFile_UserLoggedIn(){ + $sUserId = $this->CreateUserAndLogIn(); + Session::Set('login_mode', 'foo_login_mode'); + + $oSessionHandler = new SessionHandler(); + $session_id = uniqid(); + $sFile = $this->touchSessionFile($oSessionHandler, $session_id); + $this->assertEquals(true, is_file($sFile), "Should return a file name: '$sFile' is not a valid file name"); + $sFirstContent = file_get_contents($sFile); + + $iFirstCTime = filectime($sFile) - 1; + // Set it in the past to check that it will be further updated (without the need to sleep...) + touch($sFile, $iFirstCTime); + + $this->assertNotNull($sFirstContent, 'Should not return null'); + $aJson = json_decode($sFirstContent, true); + $this->assertNotEquals(false, $aJson, 'Should return a valid json string, found: '.$sFirstContent); + $this->assertEquals($sUserId, $aJson['user_id'] ?? '', "Should report the login of the logged in user in [user_id]: $sFirstContent"); + $this->assertEquals(ContextTag::TAG_REST, $aJson['context'] ?? '', "Should report the context tag(s) in [context]: $sFirstContent"); + $this->assertIsInt($aJson['creation_time'] ?? '', "Should report the session start timestamp in [creation_time]: $sFirstContent"); + $this->assertEquals('foo_login_mode', $aJson['login_mode'] ?? '', "Should report the current login mode in [login_mode]: $sFirstContent"); + + $this->touchSessionFile($oSessionHandler, $session_id); + $sNewContent = file_get_contents($sFile); + $this->assertEquals($sFirstContent, $sNewContent, 'On successive calls, should not modify an existing session file'); + $this->assertGreaterThan($iFirstCTime, filectime($sFile), 'On successive calls, should have changed the file ctime'); + } + + /** + * @covers SessionHandler::touch_session_file + */ + public function testTouchSessionFileWithEmptySessionId() { + $this->CreateUserAndLogIn(); + Session::Set('login_mode', 'toto'); + + $oSessionHandler = new SessionHandler(); + $this->assertNull($this->touchSessionFile($oSessionHandler, ''), 'Should return null when session id is an empty string'); + $this->assertNull($this->touchSessionFile($oSessionHandler, false), 'Should return null when session id (boolean) false'); + } + + private function GetFilePath(SessionHandler $oSessionHandler, $session_id) : string { + $sFile = $this->InvokeNonPublicMethod(SessionHandler::class, "get_file_path", $oSessionHandler, $aArgs = [$session_id]); + // Record file for cleanup on tearDown + $this->aFiles[] = $sFile; + return $sFile; + } + + public function GgcWithTimeLimitProvider(){ + return [ + 'no cleanup time limit' => [ + 'iTimeLimit' => -1, + 'iExpectedProcessed' => 2 + ], + 'cleanup time limit in the pass => first file removed only' => [ + 'iTimeLimit' => time() - 1, + 'iExpectedProcessed' => 1 + ], + ]; + } + + /** + * @covers SessionHandler::gc_with_time_limit + * @covers SessionHandler::list_session_files + * @dataProvider GgcWithTimeLimitProvider + */ + public function testGgcWithTimeLimit($iTimeLimit, $iExpectedProcessed) { + $oSessionHandler = new SessionHandler(); + //remove all first + $oSessionHandler->gc_with_time_limit(-1); + $this->assertEquals([], $oSessionHandler->list_session_files(), 'list_session_files should report no file at startup'); + + $max_lifetime = 1440; + $iNbExpiredFiles = 2; + $iNbFiles = 5; + $iExpiredTimeStamp = time() - $max_lifetime - 1; + for($i=0; $i<$iNbFiles; $i++) { + $sFile = $this->GetFilePath($oSessionHandler, uniqid()); + file_put_contents($sFile, "fakedata"); + + if ($iNbExpiredFiles > 0){ + $iNbExpiredFiles--; + touch($sFile, $iExpiredTimeStamp); + } + } + + $aFoundSessionFiles = $oSessionHandler->list_session_files(); + $this->assertEquals($iNbFiles, sizeof($aFoundSessionFiles), 'list_session_files should reports all files'); + foreach ($aFoundSessionFiles as $sFile){ + $this->assertTrue(is_file($sFile), 'list_session_files should return a valid file paths, found: '.$sFile); + } + + $iProcessed = $oSessionHandler->gc_with_time_limit($max_lifetime, $iTimeLimit); + $this->assertEquals($iExpectedProcessed, $iProcessed, 'gc_with_time_limit should report the count of expired files'); + $this->assertEquals($iNbFiles - $iExpectedProcessed, sizeof($oSessionHandler->list_session_files()), 'gc_with_time_limit should actually remove all processed files'); + } +} diff --git a/webservices/cron.php b/webservices/cron.php index b39ab8efe..9e8c4bec5 100644 --- a/webservices/cron.php +++ b/webservices/cron.php @@ -108,7 +108,7 @@ function RunTask(BackgroundTask $oTask, $iTimeLimit) // Time in seconds allowed to the task $iCurrTimeLimit = $iTimeLimit; // Compute allowed time - if ($oRefClass->implementsInterface('iScheduledProcess') === false) + if ($oRefClass->implementsInterface('iScheduledProcess') === false) { // Periodic task, allow only X times ($iMaxTaskExecutionTime) its periodicity (GetPeriodicity()) $iMaxTaskExecutionTime = MetaModel::GetConfig()->Get('cron_task_max_execution_time'); @@ -148,7 +148,7 @@ function RunTask(BackgroundTask $oTask, $iTimeLimit) $oTask->Set('first_run_date', $oDateStarted->format('Y-m-d H:i:s')); } $oTask->ComputeDurations($fDuration); // does increment the counter and compute statistics - + // Update the timestamp since we want to be able to re-order the tasks based on the time they finished $oDateEnded = new DateTime(); $oTask->Set('latest_run_date', $oDateEnded->format('Y-m-d H:i:s'));