Merge pull request #3495 from JohnFF/patch-5
[civicrm-core.git] / CRM / Extension / Browser.php
CommitLineData
6a488035
TO
1<?php
2/*
3 +--------------------------------------------------------------------+
06b69b18 4 | CiviCRM version 4.5 |
6a488035 5 +--------------------------------------------------------------------+
06b69b18 6 | Copyright CiviCRM LLC (c) 2004-2014 |
6a488035
TO
7 +--------------------------------------------------------------------+
8 | This file is a part of CiviCRM. |
9 | |
10 | CiviCRM is free software; you can copy, modify, and distribute it |
11 | under the terms of the GNU Affero General Public License |
12 | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
13 | |
14 | CiviCRM is distributed in the hope that it will be useful, but |
15 | WITHOUT ANY WARRANTY; without even the implied warranty of |
16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
17 | See the GNU Affero General Public License for more details. |
18 | |
19 | You should have received a copy of the GNU Affero General Public |
20 | License and the CiviCRM Licensing Exception along |
21 | with this program; if not, contact CiviCRM LLC |
22 | at info[AT]civicrm[DOT]org. If you have questions about the |
23 | GNU Affero General Public License or the licensing of CiviCRM, |
24 | see the CiviCRM license FAQ at http://civicrm.org/licensing |
25 +--------------------------------------------------------------------+
26*/
27
28/**
29 * This class glues together the various parts of the extension
30 * system.
31 *
32 * @package CRM
06b69b18 33 * @copyright CiviCRM LLC (c) 2004-2014
6a488035
TO
34 * $Id$
35 *
36 */
37class CRM_Extension_Browser {
38
39 /**
40 * An URL for public extensions repository
41 */
72b14f38 42 const DEFAULT_EXTENSIONS_REPOSITORY = 'https://civicrm.org/extdir/ver={ver}|cms={uf}';
6a488035
TO
43
44 /**
45 * @param string $repoUrl URL of the remote repository
46 * @param string $indexPath relative path of the 'index' file within the repository
47 * @param string $cacheDir local path in which to cache files
48 */
49 public function __construct($repoUrl, $indexPath, $cacheDir) {
50 $this->repoUrl = $repoUrl;
51 $this->cacheDir = $cacheDir;
52 $this->indexPath = $indexPath;
53 if ($cacheDir && !file_exists($cacheDir) && is_dir(dirname($cacheDir)) && is_writeable(dirname($cacheDir))) {
54 CRM_Utils_File::createDir($cacheDir, FALSE);
55 }
56 }
57
58 /**
59 * Determine whether the system policy allows downloading new extensions.
60 *
61 * This is reflection of *policy* and *intent*; it does not indicate whether
62 * the browser will actually *work*. For that, see checkRequirements().
63 *
64 * @return bool
65 */
66 public function isEnabled() {
67 return (FALSE !== $this->getRepositoryUrl());
68 }
69
e0ef6999
EM
70 /**
71 * @return string
72 */
6a488035
TO
73 public function getRepositoryUrl() {
74 return $this->repoUrl;
75 }
76
77 public function refresh() {
78 $file = $this->getTsPath();
79 if (file_exists($file)) {
80 unlink($file);
81 }
82 }
83
84 /**
85 * Determine whether downloading is supported
86 *
87 * @return array list of error messages; empty if OK
88 */
89 public function checkRequirements() {
90 if (!$this->isEnabled()) {
91 return array();
92 }
93
94 $errors = array();
95
96 if (!$this->cacheDir || !is_dir($this->cacheDir) || !is_writeable($this->cacheDir)) {
97 $civicrmDestination = urlencode(CRM_Utils_System::url('civicrm/admin/extensions', 'reset=1'));
98 $url = CRM_Utils_System::url('civicrm/admin/setting/path', "reset=1&civicrmDestination=${civicrmDestination}");
99 $errors[] = array(
100 'title' => ts('Directory Unwritable'),
101 'message' => ts('Your extensions cache directory (%1) is not web server writable. Please go to the <a href="%2">path setting page</a> and correct it.<br/>',
102 array(
103 1 => $this->cacheDir,
104 2 => $url,
105 )
106 )
107 );
108 }
109
110 return $errors;
111 }
112
113 /**
114 * Get a list of all available extensions
115 *
116 * @return array ($key => CRM_Extension_Info)
117 */
118 public function getExtensions() {
119 if (!$this->isEnabled() || count($this->checkRequirements())) {
120 return array();
121 }
122
123 $exts = array();
124
125 $remote = $this->_discoverRemote();
126 if (is_array($remote)) {
127 foreach ($remote as $dc => $e) {
128 $exts[$e->key] = $e;
129 }
130 }
131
132 return $exts;
133 }
134
135 /**
136 * Get a description of a particular extension
137 *
fd31fa4c
EM
138 * @param $key
139 *
6a488035
TO
140 * @return CRM_Extension_Info|NULL
141 */
142 public function getExtension($key) {
143 // TODO optimize performance -- we don't need to fetch/cache the entire repo
144 $exts = $this->getExtensions();
145 if (array_key_exists($key, $exts)) {
146 return $exts[$key];
147 } else {
148 // throw new CRM_Extension_Exception("Unknown remote extension: $key");
149 return NULL;
150 }
151 }
152
e0ef6999
EM
153 /**
154 * @return array
155 * @throws CRM_Extension_Exception_ParseException
156 */
6a488035
TO
157 private function _discoverRemote() {
158 $tsPath = $this->getTsPath();
159 $timestamp = FALSE;
160
161 if (file_exists($tsPath)) {
162 $timestamp = file_get_contents($tsPath);
163 }
164
165 // 3 minutes ago for now
166 $outdated = (int) $timestamp < (time() - 180) ? TRUE : FALSE;
167
168 if (!$timestamp || $outdated) {
169 $remotes = $this->grabRemoteKeyList();
170 $cached = FALSE;
171 }
172 else {
173 $remotes = $this->grabCachedKeyList();
174 $cached = TRUE;
175 }
176
177 $this->_remotesDiscovered = array();
178 foreach ($remotes as $id => $rext) {
179 $xml = $this->grabRemoteInfoFile($rext['key'], $cached);
180 if ($xml != FALSE) {
181 $ext = CRM_Extension_Info::loadFromString($xml);
182 $this->_remotesDiscovered[] = $ext;
183 }
184 }
185
186 if (file_exists(dirname($tsPath))) {
187 file_put_contents($tsPath, (string) time());
188 }
189
190 return $this->_remotesDiscovered;
191 }
192
e0ef6999
EM
193 /**
194 * @return array
195 */
6a488035
TO
196 private function grabCachedKeyList() {
197 $result = array();
198 $cachedPath = $this->cacheDir . DIRECTORY_SEPARATOR;
199 $files = scandir($cachedPath);
200 foreach ($files as $dc => $fname) {
201 if (substr($fname, -4) == '.xml') {
73505023 202 $result[] = array('key' => substr($fname, 0, -4));
6a488035
TO
203 }
204 }
205 return $result;
206 }
207
208 /**
209 * Connects to public server and grabs the list of publically available
210 * extensions.
211 *
212 * @access public
213 *
214 * @return Array list of extension names
215 */
216 private function grabRemoteKeyList() {
217
218 ini_set('default_socket_timeout', CRM_Utils_VersionCheck::CHECK_TIMEOUT);
219 set_error_handler(array('CRM_Extension_Browser', 'downloadError'));
220
221 if (!ini_get('allow_url_fopen')) {
222 ini_set('allow_url_fopen', 1);
223 }
224
225 if(FALSE === $this->getRepositoryUrl()) {
226 // don't check if the user has configured civi not to check an external
227 // url for extensions. See CRM-10575.
228 CRM_Core_Session::setStatus(ts('Not checking remote URL for extensions since ext_repo_url is set to false.'), ts('Check Settings'), 'alert');
229 return array();
230 }
231
7aa1b99d 232 $exts = array();
72b14f38
TO
233 list ($status, $extdir) = CRM_Utils_HttpClient::singleton()->get($this->getRepositoryUrl() . $this->indexPath);
234 if ($extdir === FALSE || $status !== CRM_Utils_HttpClient::STATUS_OK) {
6a488035 235 CRM_Core_Session::setStatus(ts('The CiviCRM public extensions directory at %1 could not be contacted - please check your webserver can make external HTTP requests or contact CiviCRM team on <a href="http://forum.civicrm.org/">CiviCRM forum</a>.<br />', array(1 => $this->getRepositoryUrl())), ts('Connection Error'), 'error');
7aa1b99d
DG
236 } else {
237 $lines = explode("\n", $extdir);
238
239 foreach ($lines as $ln) {
240 if (preg_match("@\<li\>(.*)\</li\>@i", $ln, $out)) {
241 // success
242 $extsRaw[] = $out;
243 $key = strip_tags($out[1]);
244 if (substr($key, -4) == '.xml') {
245 $exts[] = array('key' => substr($key, 0, -4));
246 }
6a488035
TO
247 }
248 }
249 }
250
70619090
DG
251 // CRM-13141 There may not be any compatible extensions available for the requested CiviCRM version + CMS. If so, $extdir is empty so just return a notification.
252 if (empty($exts)) {
253 $config = CRM_Core_Config::singleton();
254 CRM_Core_Session::setStatus(ts('There are currently no extensions on the CiviCRM public extension directory which are compatible with version %2 (<a href="%1">requested extensions from here</a>). If you want to install an extension which is not marked as compatible, you may be able to <a href="%3">download and install extensions manually</a> (depending on access to your web server).<br />', array(1 => $this->getRepositoryUrl(), 2 => $config->civiVersion, 3 => 'http://wiki.civicrm.org/confluence/display/CRMDOC/Extensions')), ts('No Extensions Available for this Version'), 'info');
255 }
256
6a488035
TO
257 ini_restore('allow_url_fopen');
258 ini_restore('default_socket_timeout');
259
260 restore_error_handler();
261
262 return $exts;
263 }
264
265 /**
266 * Given the key, retrieves the info XML from a remote server
267 * and stores locally, returning the contents.
268 *
269 * @access public
270 *
271 * @param string $key extension key
272 * @param boolean $cached whether to use cached data
273 *
274 * @return contents of info.xml, or null if info.xml cannot be retrieved or parsed
275 */
276 private function grabRemoteInfoFile($key, $cached = FALSE) {
277 $filename = $this->cacheDir . DIRECTORY_SEPARATOR . $key . '.xml';
278 $url = $this->getRepositoryUrl() . '/' . $key . '.xml';
279
280 if (!$cached || !file_exists($filename)) {
72b14f38
TO
281 $fetchStatus = CRM_Utils_HttpClient::singleton()->fetch($url, $filename);
282 if ($fetchStatus != CRM_Utils_HttpClient::STATUS_OK) {
283 return NULL;
284 }
6a488035
TO
285 }
286
287 if (file_exists($filename)) {
288 $contents = file_get_contents($filename);
289
290 //parse just in case
291 $check = simplexml_load_string($contents);
292
293 if (!$check) {
294 foreach (libxml_get_errors() as $error) {
295 CRM_Core_Error::debug('xmlError', $error);
296 }
297 return;
298 }
299
300 return $contents;
301 }
302 }
303
e0ef6999
EM
304 /**
305 * @return string
306 */
6a488035
TO
307 private function getTsPath() {
308 return $this->cacheDir . DIRECTORY_SEPARATOR . 'timestamp.txt';
309 }
310
311 /**
312 * A dummy function required for suppressing download errors
313 */
314 public static function downloadError($errorNumber, $errorString) {
315 return;
316 }
317
232624b1 318}