Merge pull request #15435 from MegaphoneJon/reporting-21
[civicrm-core.git] / CRM / Utils / VersionCheck.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 *
14 * @package CRM
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
16 */
17 class CRM_Utils_VersionCheck {
18 const
19 CACHEFILE_NAME = 'version-msgs-cache.json',
20 // After which length of time we expire the cached version info (3 days).
21 CACHEFILE_EXPIRE = 259200;
22
23 /**
24 * The version of the current (local) installation
25 *
26 * @var string
27 */
28 public $localVersion = NULL;
29
30 /**
31 * Info about available versions
32 *
33 * @var array
34 */
35 public $versionInfo = [];
36
37 /**
38 * @var bool
39 */
40 public $isInfoAvailable;
41
42 /**
43 * @var array
44 */
45 public $cronJob = [];
46
47 /**
48 * @var string
49 */
50 public $pingbackUrl = 'https://latest.civicrm.org/stable.php?format=summary';
51
52 /**
53 * Pingback params
54 *
55 * @var array
56 */
57 protected $stats = [];
58
59 /**
60 * Path to cache file
61 *
62 * @var string
63 */
64 public $cacheFile;
65
66 /**
67 * Class constructor.
68 */
69 public function __construct() {
70 $this->localVersion = CRM_Utils_System::version();
71 $this->cacheFile = CRM_Core_Config::singleton()->uploadDir . self::CACHEFILE_NAME;
72 }
73
74 /**
75 * Self-populates version info
76 *
77 * @throws \Exception
78 */
79 public function initialize() {
80 $this->getJob();
81
82 // Populate remote $versionInfo from cache file
83 $this->isInfoAvailable = $this->readCacheFile();
84
85 // Fallback if scheduled job is enabled but has failed to run.
86 $expiryTime = time() - self::CACHEFILE_EXPIRE;
87 if (!empty($this->cronJob['is_active']) &&
88 (!$this->isInfoAvailable || filemtime($this->cacheFile) < $expiryTime)
89 ) {
90 // First try updating the files modification time, for 2 reasons:
91 // - if the file is not writeable, this saves the trouble of pinging back
92 // - if the remote server is down, this will prevent an immediate retry
93 if (touch($this->cacheFile) === FALSE) {
94 throw new Exception('File not writable');
95 }
96 $this->fetch();
97 }
98 }
99
100 /**
101 * Sets $versionInfo
102 *
103 * @param $info
104 */
105 protected function setVersionInfo($info) {
106 $this->versionInfo = $info;
107 }
108
109 /**
110 * @return array|NULL
111 * message: string
112 * title: string
113 * severity: string
114 * Ex: 'info', 'notice', 'warning', 'critical'.
115 */
116 public function getVersionMessages() {
117 return $this->isInfoAvailable ? $this->versionInfo : NULL;
118 }
119
120 /**
121 * Called by version_check cron job
122 */
123 public function fetch() {
124 $this->getSiteStats();
125 $this->pingBack();
126 }
127
128 /**
129 * Collect info about the site to be sent as pingback data.
130 */
131 private function getSiteStats() {
132 $config = CRM_Core_Config::singleton();
133 $siteKey = md5(defined('CIVICRM_SITE_KEY') ? CIVICRM_SITE_KEY : '');
134
135 // Calorie-free pingback for alphas
136 $this->stats = ['version' => $this->localVersion];
137
138 // Non-alpha versions get the full treatment
139 if ($this->localVersion && !strpos($this->localVersion, 'alpha')) {
140 $this->stats += [
141 'hash' => md5($siteKey . $config->userFrameworkBaseURL),
142 'uf' => $config->userFramework,
143 'lang' => $config->lcMessages,
144 'co' => $config->defaultContactCountry,
145 'ufv' => $config->userSystem->getVersion(),
146 'PHP' => phpversion(),
147 'MySQL' => CRM_Core_DAO::singleValueQuery('SELECT VERSION()'),
148 'communityMessagesUrl' => Civi::settings()->get('communityMessagesUrl'),
149 ];
150 $this->getDomainStats();
151 $this->getPayProcStats();
152 $this->getEntityStats();
153 $this->getExtensionStats();
154 }
155 }
156
157 /**
158 * Get active payment processor types.
159 */
160 private function getPayProcStats() {
161 $dao = new CRM_Financial_DAO_PaymentProcessor();
162 $dao->is_active = 1;
163 $dao->find();
164 $ppTypes = [];
165
166 // Get title for all processor types
167 // FIXME: This should probably be getName, but it has always returned translated label so we stick with that for now as it would affect stats
168 while ($dao->fetch()) {
169 $ppTypes[] = CRM_Core_PseudoConstant::getLabel('CRM_Financial_BAO_PaymentProcessor', 'payment_processor_type_id', $dao->payment_processor_type_id);
170 }
171 // add the .-separated list of the processor types
172 $this->stats['PPTypes'] = implode(',', array_unique($ppTypes));
173 }
174
175 /**
176 * Fetch counts from entity tables.
177 * Add info to the 'entities' array
178 */
179 private function getEntityStats() {
180 $tables = [
181 'CRM_Activity_DAO_Activity' => 'is_test = 0',
182 'CRM_Case_DAO_Case' => 'is_deleted = 0',
183 'CRM_Contact_DAO_Contact' => 'is_deleted = 0',
184 'CRM_Contact_DAO_Relationship' => NULL,
185 'CRM_Campaign_DAO_Campaign' => NULL,
186 'CRM_Contribute_DAO_Contribution' => 'is_test = 0',
187 'CRM_Contribute_DAO_ContributionPage' => 'is_active = 1',
188 'CRM_Contribute_DAO_ContributionProduct' => NULL,
189 'CRM_Contribute_DAO_Widget' => 'is_active = 1',
190 'CRM_Core_DAO_Discount' => NULL,
191 'CRM_Price_DAO_PriceSetEntity' => NULL,
192 'CRM_Core_DAO_UFGroup' => 'is_active = 1',
193 'CRM_Event_DAO_Event' => 'is_active = 1',
194 'CRM_Event_DAO_Participant' => 'is_test = 0',
195 'CRM_Friend_DAO_Friend' => 'is_active = 1',
196 'CRM_Grant_DAO_Grant' => NULL,
197 'CRM_Mailing_DAO_Mailing' => 'is_completed = 1',
198 'CRM_Member_DAO_Membership' => 'is_test = 0',
199 'CRM_Member_DAO_MembershipBlock' => 'is_active = 1',
200 'CRM_Pledge_DAO_Pledge' => 'is_test = 0',
201 'CRM_Pledge_DAO_PledgeBlock' => NULL,
202 'CRM_Mailing_Event_DAO_Delivered' => NULL,
203 ];
204 foreach ($tables as $daoName => $where) {
205 $dao = new $daoName();
206 if ($where) {
207 $dao->whereAdd($where);
208 }
209 $short_name = substr($daoName, strrpos($daoName, '_') + 1);
210 $this->stats['entities'][] = [
211 'name' => $short_name,
212 'size' => $dao->count(),
213 ];
214 }
215 }
216
217 /**
218 * Fetch stats about enabled components/extensions
219 * Add info to the 'extensions' array
220 */
221 private function getExtensionStats() {
222 // Core components
223 $config = CRM_Core_Config::singleton();
224 foreach ($config->enableComponents as $comp) {
225 $this->stats['extensions'][] = [
226 'name' => 'org.civicrm.component.' . strtolower($comp),
227 'enabled' => 1,
228 'version' => $this->stats['version'],
229 ];
230 }
231 // Contrib extensions
232 $mapper = CRM_Extension_System::singleton()->getMapper();
233 $dao = new CRM_Core_DAO_Extension();
234 $dao->find();
235 while ($dao->fetch()) {
236 $info = $mapper->keyToInfo($dao->full_name);
237 $this->stats['extensions'][] = [
238 'name' => $dao->full_name,
239 'enabled' => $dao->is_active,
240 'version' => $info->version ?? NULL,
241 ];
242 }
243 }
244
245 /**
246 * Fetch stats about domain and add to 'stats' array.
247 */
248 private function getDomainStats() {
249 // Start with default value NULL, then check to see if there's a better
250 // value to be had.
251 $this->stats['domain_isoCode'] = NULL;
252 $params = [
253 'id' => CRM_Core_Config::domainID(),
254 ];
255 $domain_result = civicrm_api3('domain', 'getsingle', $params);
256 if (!empty($domain_result['contact_id'])) {
257 $address_params = [
258 'contact_id' => $domain_result['contact_id'],
259 'is_primary' => 1,
260 'sequential' => 1,
261 ];
262 $address_result = civicrm_api3('address', 'get', $address_params);
263 if ($address_result['count'] == 1 && !empty($address_result['values'][0]['country_id'])) {
264 $country_params = [
265 'id' => $address_result['values'][0]['country_id'],
266 ];
267 $country_result = civicrm_api3('country', 'getsingle', $country_params);
268 if (!empty($country_result['iso_code'])) {
269 $this->stats['domain_isoCode'] = $country_result['iso_code'];
270 }
271 }
272 }
273 }
274
275 /**
276 * Send the request to civicrm.org
277 * Store results in the cache file
278 */
279 private function pingBack() {
280 $params = [
281 'http' => [
282 'method' => 'POST',
283 'header' => 'Content-type: application/x-www-form-urlencoded',
284 'content' => http_build_query($this->stats),
285 ],
286 ];
287 $ctx = stream_context_create($params);
288 $rawJson = file_get_contents($this->pingbackUrl, FALSE, $ctx);
289 $versionInfo = $rawJson ? json_decode($rawJson, TRUE) : NULL;
290 // If we couldn't fetch or parse the data $versionInfo will be NULL
291 // Otherwise it will be an array and we'll cache it.
292 // Note the array may be empty e.g. in the case of a pre-alpha with no releases
293 $this->isInfoAvailable = $versionInfo !== NULL;
294 if ($this->isInfoAvailable) {
295 $this->writeCacheFile($rawJson);
296 $this->setVersionInfo($versionInfo);
297 }
298 }
299
300 /**
301 * @return bool
302 */
303 private function readCacheFile() {
304 if (file_exists($this->cacheFile)) {
305 $this->setVersionInfo(json_decode(file_get_contents($this->cacheFile), TRUE));
306 return TRUE;
307 }
308 return FALSE;
309 }
310
311 /**
312 * Save version info to file.
313 * @param string $contents
314 * @throws \Exception
315 */
316 private function writeCacheFile($contents) {
317 if (file_put_contents($this->cacheFile, $contents) === FALSE) {
318 throw new Exception('File not writable');
319 }
320 }
321
322 /**
323 * Removes cached version info.
324 */
325 public function flushCache() {
326 if (file_exists($this->cacheFile)) {
327 unlink($this->cacheFile);
328 }
329 }
330
331 /**
332 * Lookup version_check scheduled job
333 */
334 private function getJob() {
335 $jobs = civicrm_api3('Job', 'get', [
336 'sequential' => 1,
337 'api_action' => "version_check",
338 'api_entity' => "job",
339 ]);
340 $this->cronJob = CRM_Utils_Array::value(0, $jobs['values'], []);
341 }
342
343 }