From fa8dc18c7fc2cc6b47e7d299e4e29e94d0de0242 Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Fri, 19 Dec 2014 21:54:28 -0500 Subject: [PATCH] CRM-13672 - Add alerts for security releases Support the new json output from latest.civicrm.org and display security alerts. Also adds finer-grained control over how version alerts are displayed. --- CRM/Admin/Form/Setting.php | 16 +- CRM/Admin/Form/Setting/Miscellaneous.php | 1 + CRM/Core/Config/Variables.php | 7 - CRM/Core/Invoke.php | 12 +- CRM/Utils/VersionCheck.php | 279 +++++++++++++----- settings/Core.setting.php | 46 ++- .../CRM/Admin/Form/Setting/Miscellaneous.tpl | 39 ++- templates/CRM/common/footer.tpl | 9 +- 8 files changed, 298 insertions(+), 111 deletions(-) diff --git a/CRM/Admin/Form/Setting.php b/CRM/Admin/Form/Setting.php index ad9056d2bf..bca622fbbe 100644 --- a/CRM/Admin/Form/Setting.php +++ b/CRM/Admin/Form/Setting.php @@ -139,20 +139,22 @@ class CRM_Admin_Form_Setting extends CRM_Core_Form { foreach ($this->_settings as $setting => $group){ $settingMetaData = civicrm_api('setting', 'getfields', array('version' => 3, 'name' => $setting)); - if(isset($settingMetaData['values'][$setting]['quick_form_type'])){ - $add = 'add' . $settingMetaData['values'][$setting]['quick_form_type']; + $props = $settingMetaData['values'][$setting]; + if(isset($props['quick_form_type'])){ + $add = 'add' . $props['quick_form_type']; if($add == 'addElement'){ $this->$add( - $settingMetaData['values'][$setting]['html_type'], + $props['html_type'], $setting, - ts($settingMetaData['values'][$setting]['title']), - CRM_Utils_Array::value('html_attributes', $settingMetaData['values'][$setting], array()) + ts($props['title']), + CRM_Utils_Array::value($props['html_type'] == 'select' ? 'option_values' : 'html_attributes', $props, array()), + $props['html_type'] == 'select' ? CRM_Utils_Array::value('html_attributes', $props) : NULL ); } else{ - $this->$add($setting, ts($settingMetaData['values'][$setting]['title'])); + $this->$add($setting, ts($props['title'])); } - $this->assign("{$setting}_description", ts($settingMetaData['values'][$setting]['description'])); + $this->assign("{$setting}_description", ts($props['description'])); if($setting == 'max_attachments'){ //temp hack @todo fix to get from metadata $this->addRule('max_attachments', ts('Value should be a positive number'), 'positiveInteger'); diff --git a/CRM/Admin/Form/Setting/Miscellaneous.php b/CRM/Admin/Form/Setting/Miscellaneous.php index bf8513508b..f5b03e0d74 100644 --- a/CRM/Admin/Form/Setting/Miscellaneous.php +++ b/CRM/Admin/Form/Setting/Miscellaneous.php @@ -43,6 +43,7 @@ class CRM_Admin_Form_Setting_Miscellaneous extends CRM_Admin_Form_Setting { 'max_attachments' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'contact_undelete' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'versionAlert' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, + 'securityUpdateAlert' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'versionCheck' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'empoweredBy' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'maxFileSize' => CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, diff --git a/CRM/Core/Config/Variables.php b/CRM/Core/Config/Variables.php index f05464dd7e..de6cc2c2b1 100644 --- a/CRM/Core/Config/Variables.php +++ b/CRM/Core/Config/Variables.php @@ -324,13 +324,6 @@ class CRM_Core_Config_Variables extends CRM_Core_Config_Defaults { */ public $logging = FALSE; - /** - * Whether CiviCRM should check for newer versions - * - * @var boolean - */ - public $versionCheck = TRUE; - /** * Whether public pages should display "empowered by CiviCRM" * diff --git a/CRM/Core/Invoke.php b/CRM/Core/Invoke.php index d7c3b39a3e..497d552639 100644 --- a/CRM/Core/Invoke.php +++ b/CRM/Core/Invoke.php @@ -367,15 +367,21 @@ class CRM_Core_Invoke { /** * Show the message about CiviCRM versions * - * @param obj: $template (reference) + * @param CRM_Core_Smarty $template */ static function versionCheck($template) { if (CRM_Core_Config::isUpgradeMode()) { return; } - $versionCheck = CRM_Utils_VersionCheck::singleton(); - $newerVersion = $versionCheck->newerVersion(); + $newerVersion = $securityUpdate = NULL; + if (CRM_Core_BAO_Setting::getItem(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'versionAlert', NULL, 1) & 1) { + $newerVersion = CRM_Utils_VersionCheck::singleton()->isNewerVersionAvailable(); + } + if (CRM_Core_BAO_Setting::getItem(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'securityUpdateAlert', NULL, 3) & 1) { + $securityUpdate = CRM_Utils_VersionCheck::singleton()->isSecurityUpdateAvailable(); + } $template->assign('newer_civicrm_version', $newerVersion); + $template->assign('security_update', $securityUpdate); } /** diff --git a/CRM/Utils/VersionCheck.php b/CRM/Utils/VersionCheck.php index 15a9572f97..973246a823 100644 --- a/CRM/Utils/VersionCheck.php +++ b/CRM/Utils/VersionCheck.php @@ -34,13 +34,13 @@ */ class CRM_Utils_VersionCheck { CONST - LATEST_VERSION_AT = 'http://latest.civicrm.org/stable.php', + PINGBACK_URL = 'http://latest.civicrm.org/stable.php?format=json', // timeout for when the connection or the server is slow CHECK_TIMEOUT = 5, // relative to $civicrm_root LOCALFILE_NAME = 'civicrm-version.php', // relative to $config->uploadDir - CACHEFILE_NAME = 'latest-version-cache.txt', + CACHEFILE_NAME = 'version-info-cache.json', // cachefile expiry time (in seconds) - one day CACHEFILE_EXPIRE = 86400; @@ -61,18 +61,32 @@ class CRM_Utils_VersionCheck { public $localVersion = NULL; /** - * The latest version of CiviCRM + * The major version (branch name) of the local version * * @var string */ - public $latestVersion = NULL; + public $localMajorVersion; + + /** + * Info about available versions + * + * @var array + */ + public $versionInfo = array(); /** * Pingback params * - * @var string + * @var array */ protected $stats = array(); + + /** + * Path to cache file + * + * @var string + */ + protected $cacheFile; /** * Class constructor @@ -83,63 +97,46 @@ class CRM_Utils_VersionCheck { global $civicrm_root; $config = CRM_Core_Config::singleton(); - $localfile = $civicrm_root . DIRECTORY_SEPARATOR . self::LOCALFILE_NAME; - $cachefile = $config->uploadDir . self::CACHEFILE_NAME; + $localFile = $civicrm_root . DIRECTORY_SEPARATOR . self::LOCALFILE_NAME; + $this->cacheFile = $config->uploadDir . self::CACHEFILE_NAME; - if (file_exists($localfile)) { - require_once ($localfile); - if (function_exists('civicrmVersion')) { - $info = civicrmVersion(); - $this->localVersion = trim($info['version']); - } + if (file_exists($localFile)) { + require_once ($localFile); } - if ($config->versionCheck) { - $expiryTime = time() - self::CACHEFILE_EXPIRE; - - // if there's a cachefile and it's not stale use it to - // read the latestVersion, else read it from the Internet - if (file_exists($cachefile) && (filemtime($cachefile) > $expiryTime)) { - $this->latestVersion = trim(file_get_contents($cachefile)); - } - else { - $siteKey = md5(defined('CIVICRM_SITE_KEY') ? CIVICRM_SITE_KEY : ''); - - $this->stats = array( - 'hash' => md5($siteKey . $config->userFrameworkBaseURL), - 'version' => $this->localVersion, - 'uf' => $config->userFramework, - 'lang' => $config->lcMessages, - 'co' => $config->defaultContactCountry, - 'ufv' => $config->userFrameworkVersion, - 'PHP' => phpversion(), - 'MySQL' => CRM_CORE_DAO::singleValueQuery('SELECT VERSION()'), - 'communityMessagesUrl' => CRM_Core_BAO_Setting::getItem(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'communityMessagesUrl', NULL, '*default*'), - ); - - // Add usage stats - $this->payProcStats(); - $this->entityStats(); - $this->extensionStats(); + if (function_exists('civicrmVersion')) { + $info = civicrmVersion(); + $this->localVersion = trim($info['version']); + $this->localMajorVersion = $this->getMajorVersion($this->localVersion); + } + if (CRM_Core_BAO_Setting::getItem(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'versionCheck', NULL, 1)) { + // Use cached data if available and not stale + if (!$this->readCacheFile()) { + // Collect stats for pingback + $this->getSiteStats(); // Get the latest version and send site info $this->pingBack(); + } + } + // Make sure version info is in ascending order for easier comparisons + ksort($this->versionInfo, SORT_NUMERIC); + } - // Update cache file - if ($this->latestVersion) { - $fp = @fopen($cachefile, 'w'); - if (!$fp) { - if (CRM_Core_Permission::check('administer CiviCRM')) { - CRM_Core_Session::setStatus( - ts('Unable to write file') . ":$cachefile
" . ts('Please check your system file permissions.'), - ts('File Error'), 'error'); - } - return; - } - fwrite($fp, $this->latestVersion); - fclose($fp); + /** + * Magic property accessor + * @param $variable + * @return mixed + */ + function __get($variable) { + switch ($variable) { + case "localVersionStatus": + if ($this->localVersion && $this->versionInfo) { + $versionInfo = CRM_Utils_Array::value($this->localMajorVersion, $this->versionInfo); + return CRM_Utils_Array::value('status', $versionInfo); } - } + return NULL; } + return NULL; } /** @@ -157,20 +154,75 @@ class CRM_Utils_VersionCheck { return self::$_singleton; } + /** + * Finds the release info for a minor version + * @param string $version + * @return array|null + */ + public function getReleaseInfo($version) { + $majorVersion = $this->getMajorVersion($version); + if (isset($this->versionInfo[$majorVersion])) { + foreach ($this->versionInfo[$majorVersion]['releases'] as $info) { + if ($info['version'] == $version) { + return $info; + } + } + } + return NULL; + } + + /** + * @param $minorVersion + * @return string + */ + public function getMajorVersion($minorVersion) { + if (!$minorVersion) { + return NULL; + } + list($a, $b) = explode('.', $minorVersion); + return "$a.$b"; + } + + public function isSecurityUpdateAvailable() { + $thisVersion = $this->getReleaseInfo($this->localVersion); + $localVersionDate = CRM_Utils_Array::value('date', $thisVersion, 0); + foreach ($this->versionInfo as $majorVersion) { + foreach ($majorVersion['releases'] as $release) { + if (!empty($release['security']) && $release['date'] > $localVersionDate) { + return TRUE; + } + } + } + } + /** * Get the latest version number if it's newer than the local one * * @return string|null * Returns the newer version's number, or null if the versions are equal */ - public function newerVersion() { - if ($this->latestVersion) { - if ((version_compare($this->localVersion, $this->latestVersion) < 0) - && CRM_Core_BAO_Setting::getItem(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'versionAlert', NULL, TRUE)) { - return $this->latestVersion; + public function isNewerVersionAvailable() { + $newerVersion = NULL; + if ($this->versionInfo && $this->localVersion) { + // If using an alpha or beta, find the absolute latest available + $latest = end($this->versionInfo); + // Otherwise find the latest stable version available + if ($this->localVersionStatus != 'testing') { + foreach ($this->versionInfo as $majorVersion) { + if ($majorVersion['status'] == 'stable') { + $latest = $majorVersion; + } + } + } + if ($latest && !empty($latest['releases'])) { + foreach ($latest['releases'] as $release) { + if (version_compare($this->localVersion, $release['version']) < 0) { + $newerVersion = $release['version']; + } + } } } - return NULL; + return $newerVersion; } /** @@ -178,21 +230,64 @@ class CRM_Utils_VersionCheck { * Show the message once a day */ public function versionAlert() { - if (CRM_Core_Permission::check('administer CiviCRM') && $this->newerVersion() - && CRM_Core_BAO_Setting::getItem(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'versionAlert', NULL, TRUE)) { + $versionAlertSetting = CRM_Core_BAO_Setting::getItem(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'versionAlert', NULL, 1); + $securityAlertSetting = CRM_Core_BAO_Setting::getItem(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'securityUpdateAlert', NULL, 3); + if (CRM_Core_Permission::check('administer CiviCRM') && $securityAlertSetting > 1 && $this->isSecurityUpdateAvailable()) { $session = CRM_Core_Session::singleton(); if ($session->timer('version_alert', 24 * 60 * 60)) { - $msg = ts('A newer version of CiviCRM is available: %1', array(1 => $this->latestVersion)) - . '
' . ts('Download Now', array(1 => 'http://civicrm.org/download')); - $session->setStatus($msg, ts('Update Available')); + $msg = ts('This version of CiviCRM requires a security update.') . + ''; + $session->setStatus($msg, ts('Security Alert')); } } + elseif (CRM_Core_Permission::check('administer CiviCRM') && $versionAlertSetting > 1) { + $newerVersion = $this->isNewerVersionAvailable(); + if ($newerVersion) { + $session = CRM_Core_Session::singleton(); + if ($session->timer('version_alert', 24 * 60 * 60)) { + $msg = ts('A newer version of CiviCRM is available: %1', array(1 => $newerVersion)) + . '
' . ts('Download now') . ''; + $session->setStatus($msg, ts('Update Available'), 'info'); + } + } + } + } + + /** + * Collect info about the site to be sent as pingback data + */ + private function getSiteStats() { + $config = CRM_Core_Config::singleton(); + $siteKey = md5(defined('CIVICRM_SITE_KEY') ? CIVICRM_SITE_KEY : ''); + + // Calorie-free pingback for alphas + $this->stats = array('version' => $this->localVersion); + + // Non-alpha versions get the full treatment + if ($this->localVersion && !strpos($this->localVersion, 'alpha')) { + $this->stats += array( + 'hash' => md5($siteKey . $config->userFrameworkBaseURL), + 'uf' => $config->userFramework, + 'lang' => $config->lcMessages, + 'co' => $config->defaultContactCountry, + 'ufv' => $config->userFrameworkVersion, + 'PHP' => phpversion(), + 'MySQL' => CRM_CORE_DAO::singleValueQuery('SELECT VERSION()'), + 'communityMessagesUrl' => CRM_Core_BAO_Setting::getItem(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'communityMessagesUrl', NULL, '*default*'), + ); + $this->getPayProcStats(); + $this->getEntityStats(); + $this->getExtensionStats(); + } } /** * Get active payment processor types */ - private function payProcStats() { + private function getPayProcStats() { $dao = new CRM_Financial_DAO_PaymentProcessor; $dao->is_active = 1; $dao->find(); @@ -206,14 +301,13 @@ class CRM_Utils_VersionCheck { } // add the .-separated list of the processor types $this->stats['PPTypes'] = implode(',', array_unique($ppTypes)); - } /** * Fetch counts from entity tables * Add info to the 'entities' array */ - private function entityStats() { + private function getEntityStats() { $tables = array( 'CRM_Activity_DAO_Activity' => 'is_test = 0', 'CRM_Case_DAO_Case' => 'is_deleted = 0', @@ -254,7 +348,7 @@ class CRM_Utils_VersionCheck { * Fetch stats about enabled components/extensions * Add info to the 'extensions' array */ - private function extensionStats() { + private function getExtensionStats() { // Core components $config = CRM_Core_Config::singleton(); foreach ($config->enableComponents as $comp) { @@ -281,6 +375,7 @@ class CRM_Utils_VersionCheck { /** * Send the request to civicrm.org * Set timeout and suppress errors + * Store results in the cache file */ private function pingBack() { ini_set('default_socket_timeout', self::CHECK_TIMEOUT); @@ -292,14 +387,48 @@ class CRM_Utils_VersionCheck { ), ); $ctx = stream_context_create($params); - $this->latestVersion = @file_get_contents(self::LATEST_VERSION_AT, FALSE, $ctx); - if (!preg_match('/^\d+\.\d+\.\d+$/', $this->latestVersion)) { - $this->latestVersion = NULL; - } - else { - $this->latestVersion = trim($this->latestVersion); + $rawJson = @file_get_contents(self::PINGBACK_URL, FALSE, $ctx); + $versionInfo = $rawJson ? json_decode($rawJson, TRUE) : NULL; + // If we couldn't fetch or parse the data $versionInfo will be NULL + // Otherwise it will be an array and we'll cache it. + // Note the array may be empty e.g. in the case of a pre-alpha with no releases + if ($versionInfo !== NULL) { + $this->writeCacheFile($rawJson); + $this->versionInfo = $versionInfo; } ini_restore('default_socket_timeout'); } + /** + * @return bool + */ + private function readCacheFile() { + $expiryTime = time() - self::CACHEFILE_EXPIRE; + + // if there's a cachefile and it's not stale, use it + if (file_exists($this->cacheFile) && (filemtime($this->cacheFile) > $expiryTime)) { + $this->versionInfo = (array) json_decode(file_get_contents($this->cacheFile), TRUE); + return TRUE; + } + return FALSE; + } + + /** + * Save version info to file + * @param string $contents + */ + private function writeCacheFile($contents) { + $fp = @fopen($this->cacheFile, 'w'); + if (!$fp) { + if (CRM_Core_Permission::check('administer CiviCRM')) { + CRM_Core_Session::setStatus( + ts('Unable to write file') . ": $this->cacheFile
" . ts('Please check your system file permissions.'), + ts('File Error'), 'error'); + } + return; + } + fwrite($fp, $contents); + fclose($fp); + } + } diff --git a/settings/Core.setting.php b/settings/Core.setting.php index 449c8d1ba5..ffd41021b3 100644 --- a/settings/Core.setting.php +++ b/settings/Core.setting.php @@ -355,43 +355,67 @@ return array ( 'group_name' => 'CiviCRM Preferences', 'group' => 'core', 'name' => 'versionAlert', - 'type' => 'Boolean', - 'quick_form_type' => 'YesNo', + 'type' => 'Integer', + 'quick_form_type' => 'Element', + 'html_type' => 'select', + 'option_values' => array( + ts('Disabled'), + ts('Display In Page Footer'), + ts('Display As Popup Alert'), + ts('Page Footer + Popup Alert'), + ), 'default' => 1, 'add' => '4.3', 'title' => 'New Version Alerts', 'is_domain' => 1, 'is_contact' => 0, - 'description' => 'Displays an on-screen alert to users with "Administer CiviCRM" permissions when a new version of CiviCRM is available. This setting will only work if the "Version Check & Statistics Reporting" setting is enabled.', + 'description' => "", + 'help_text' => null, + ), + 'securityUpdateAlert' => array( + 'group_name' => 'CiviCRM Preferences', + 'group' => 'core', + 'name' => 'securityUpdateAlert', + 'type' => 'Integer', + 'quick_form_type' => 'Element', + 'html_type' => 'select', + 'option_values' => array( + ts('Disabled'), + ts('Display In Page Footer'), + ts('Display As Popup Alert'), + ts('Page Footer + Popup Alert'), + ), + 'default' => 3, + 'add' => '4.6', + 'title' => 'Security Update Alerts', + 'is_domain' => 1, + 'is_contact' => 0, + 'description' => "", 'help_text' => null, ), 'versionCheck' => array( 'group_name' => 'CiviCRM Preferences', 'group' => 'core', 'name' => 'versionCheck', - 'prefetch' => 1, // prefetch causes it to be cached in config settings. Usually this is a transitional setting. Some things like urls are permanent. Remove this comment if you have assessed & it should be permanent - 'config_only'=> 1, //@todo - see https://wiki.civicrm.org/confluence/display/CRMDOC/Settings+Reference#SettingsReference-Convertingaconfigobjecttoasetting on removing this deprecated value 'type' => 'Boolean', 'quick_form_type' => 'YesNo', 'default' => 1, 'add' => '4.3', - 'title' => 'Version Check & Statistics Reporting', + 'title' => 'Automatically Check for Updates', 'is_domain' => 1, 'is_contact' => 0, - 'description' => "If enabled, CiviCRM automatically checks availablity of a newer version of the software. New version alerts will be displayed on the main CiviCRM Administration page. -When enabled, statistics about your CiviCRM installation are reported anonymously to the CiviCRM team to assist in prioritizing ongoing development efforts. The following information is gathered: CiviCRM version, versions of PHP, MySQL and framework (Drupal/Joomla/standalone), and default language. Counts (but no actual data) of the following record types are reported: contacts, activities, cases, relationships, contributions, contribution pages, contribution products, contribution widgets, discounts, price sets, profiles, events, participants, tell-a-friend pages, grants, mailings, memberships, membership blocks, pledges, pledge blocks and active payment processor types.", + 'description' => "", 'help_text' => null, ), 'securityAlert' => array( 'group_name' => 'CiviCRM Preferences', 'group' => 'core', - 'name' => 'versionCheck', - 'prefetch' => 0, + 'name' => 'securityAlert', 'type' => 'Boolean', 'quick_form_type' => 'YesNo', 'default' => 1, 'add' => '4.4', - 'title' => 'Security Alerts', + 'title' => 'Security Audits', 'is_domain' => 1, 'is_contact' => 0, 'description' => "If enabled, CiviCRM will automatically run checks for significant mis-configurations such as ineffective file protections.", diff --git a/templates/CRM/Admin/Form/Setting/Miscellaneous.tpl b/templates/CRM/Admin/Form/Setting/Miscellaneous.tpl index 7b9f82177c..7b3f84a9db 100644 --- a/templates/CRM/Admin/Form/Setting/Miscellaneous.tpl +++ b/templates/CRM/Admin/Form/Setting/Miscellaneous.tpl @@ -72,16 +72,21 @@

{ts 1="http://wkhtmltopdf.org/"}wkhtmltopdf is an alternative utility for generating PDF's which may provide better performance especially if you are generating a large number of PDF letters or receipts. Your system administrator will need to download and install this utility, and enter the executable path here.{/ts}

- - {$form.versionAlert.label} - {$form.versionAlert.html}
-

{ts}Displays an on-screen alert to users with "Administer CiviCRM" permissions when a new version of CiviCRM is available. This setting will only work if the "Version Check & Statistics Reporting" setting is enabled.{/ts}

- {$form.versionCheck.label} {$form.versionCheck.html}

{ts}When enabled, statistics about your CiviCRM installation are reported anonymously to the CiviCRM team to assist in prioritizing ongoing development efforts. The following information is gathered: CiviCRM version, versions of PHP, MySQL and framework (Drupal/Joomla/standalone), and default language. Counts (but no actual data) of the following record types are reported: contacts, activities, cases, relationships, contributions, contribution pages, contribution products, contribution widgets, discounts, price sets, profiles, events, participants, tell-a-friend pages, grants, mailings, memberships, membership blocks, pledges, pledge blocks and active payment processor types.{/ts}

+ + {$form.securityUpdateAlert.label} + {$form.securityUpdateAlert.html}
+

{ts}Alert site admins about security advisories. Strongly recommended.{/ts}

+ + + {$form.versionAlert.label} + {$form.versionAlert.html}
+

{ts}Alert site admins about all new CiviCRM releases.{/ts}

+ {$form.empoweredBy.label} {$form.empoweredBy.html}
@@ -126,3 +131,27 @@
{include file="CRM/common/formButtons.tpl" location="bottom"}
+{literal} + +{/literal} \ No newline at end of file diff --git a/templates/CRM/common/footer.tpl b/templates/CRM/common/footer.tpl index c071593895..97273b1ab1 100644 --- a/templates/CRM/common/footer.tpl +++ b/templates/CRM/common/footer.tpl @@ -31,12 +31,15 @@ -- 2.25.1