Merge pull request #583 from yashodha/CRM-12463
[civicrm-core.git] / CRM / Extension / Browser.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | CiviCRM version 4.3 |
5 +--------------------------------------------------------------------+
6 | Copyright CiviCRM LLC (c) 2004-2013 |
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
33 * @copyright CiviCRM LLC (c) 2004-2013
34 * $Id$
35 *
36 */
37 class CRM_Extension_Browser {
38
39 /**
40 * An URL for public extensions repository
41 */
42 const DEFAULT_EXTENSIONS_REPOSITORY = 'http://civicrm.org/extdir/ver={ver}|cms={uf}';
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
70 public function getRepositoryUrl() {
71 return $this->repoUrl;
72 }
73
74 public function refresh() {
75 $file = $this->getTsPath();
76 if (file_exists($file)) {
77 unlink($file);
78 }
79 }
80
81 /**
82 * Determine whether downloading is supported
83 *
84 * @return array list of error messages; empty if OK
85 */
86 public function checkRequirements() {
87 if (!$this->isEnabled()) {
88 return array();
89 }
90
91 $errors = array();
92
93 if (!$this->cacheDir || !is_dir($this->cacheDir) || !is_writeable($this->cacheDir)) {
94 $civicrmDestination = urlencode(CRM_Utils_System::url('civicrm/admin/extensions', 'reset=1'));
95 $url = CRM_Utils_System::url('civicrm/admin/setting/path', "reset=1&civicrmDestination=${civicrmDestination}");
96 $errors[] = array(
97 'title' => ts('Directory Unwritable'),
98 '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/>',
99 array(
100 1 => $this->cacheDir,
101 2 => $url,
102 )
103 )
104 );
105 }
106
107 return $errors;
108 }
109
110 /**
111 * Get a list of all available extensions
112 *
113 * @return array ($key => CRM_Extension_Info)
114 */
115 public function getExtensions() {
116 if (!$this->isEnabled() || count($this->checkRequirements())) {
117 return array();
118 }
119
120 $exts = array();
121
122 $remote = $this->_discoverRemote();
123 if (is_array($remote)) {
124 foreach ($remote as $dc => $e) {
125 $exts[$e->key] = $e;
126 }
127 }
128
129 return $exts;
130 }
131
132 /**
133 * Get a description of a particular extension
134 *
135 * @return CRM_Extension_Info|NULL
136 */
137 public function getExtension($key) {
138 // TODO optimize performance -- we don't need to fetch/cache the entire repo
139 $exts = $this->getExtensions();
140 if (array_key_exists($key, $exts)) {
141 return $exts[$key];
142 } else {
143 // throw new CRM_Extension_Exception("Unknown remote extension: $key");
144 return NULL;
145 }
146 }
147
148 private function _discoverRemote() {
149 $tsPath = $this->getTsPath();
150 $timestamp = FALSE;
151
152 if (file_exists($tsPath)) {
153 $timestamp = file_get_contents($tsPath);
154 }
155
156 // 3 minutes ago for now
157 $outdated = (int) $timestamp < (time() - 180) ? TRUE : FALSE;
158
159 if (!$timestamp || $outdated) {
160 $remotes = $this->grabRemoteKeyList();
161 $cached = FALSE;
162 }
163 else {
164 $remotes = $this->grabCachedKeyList();
165 $cached = TRUE;
166 }
167
168 $this->_remotesDiscovered = array();
169 foreach ($remotes as $id => $rext) {
170 $xml = $this->grabRemoteInfoFile($rext['key'], $cached);
171 if ($xml != FALSE) {
172 $ext = CRM_Extension_Info::loadFromString($xml);
173 $this->_remotesDiscovered[] = $ext;
174 }
175 }
176
177 if (file_exists(dirname($tsPath))) {
178 file_put_contents($tsPath, (string) time());
179 }
180
181 return $this->_remotesDiscovered;
182 }
183
184 private function grabCachedKeyList() {
185 $result = array();
186 $cachedPath = $this->cacheDir . DIRECTORY_SEPARATOR;
187 $files = scandir($cachedPath);
188 foreach ($files as $dc => $fname) {
189 if (substr($fname, -4) == '.xml') {
190 $result[] = array('key' => substr($fname, 0, -4));
191 }
192 }
193 return $result;
194 }
195
196 /**
197 * Connects to public server and grabs the list of publically available
198 * extensions.
199 *
200 * @access public
201 *
202 * @return Array list of extension names
203 */
204 private function grabRemoteKeyList() {
205
206 ini_set('default_socket_timeout', CRM_Utils_VersionCheck::CHECK_TIMEOUT);
207 set_error_handler(array('CRM_Extension_Browser', 'downloadError'));
208
209 if (!ini_get('allow_url_fopen')) {
210 ini_set('allow_url_fopen', 1);
211 }
212
213 if(FALSE === $this->getRepositoryUrl()) {
214 // don't check if the user has configured civi not to check an external
215 // url for extensions. See CRM-10575.
216 CRM_Core_Session::setStatus(ts('Not checking remote URL for extensions since ext_repo_url is set to false.'), ts('Check Settings'), 'alert');
217 return array();
218 }
219
220 $extdir = file_get_contents($this->getRepositoryUrl() . $this->indexPath);
221
222 if ($extdir === FALSE) {
223 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');
224 }
225
226 $lines = explode("\n", $extdir);
227
228 foreach ($lines as $ln) {
229 if (preg_match("@\<li\>(.*)\</li\>@i", $ln, $out)) {
230 // success
231 $extsRaw[] = $out;
232 $key = strip_tags($out[1]);
233 if (substr($key, -4) == '.xml') {
234 $exts[] = array('key' => substr($key, 0, -4));
235 }
236 }
237 }
238
239 if (empty($exts)) {
240 if ($extdir !== FALSE) {
241 CRM_Core_Session::setStatus(ts('Could not retrieve a list of extensions from the CiviCRM public directory at %1 - please contact CiviCRM team on <a href="http://forum.civicrm.org/">CiviCRM forum</a>.<br />', array(1 => $this->getRepositoryUrl())), ts('Failed Fetching List'), 'error');
242 }
243 $exts = array();
244 }
245
246 ini_restore('allow_url_fopen');
247 ini_restore('default_socket_timeout');
248
249 restore_error_handler();
250
251 return $exts;
252 }
253
254 /**
255 * Given the key, retrieves the info XML from a remote server
256 * and stores locally, returning the contents.
257 *
258 * @access public
259 *
260 * @param string $key extension key
261 * @param boolean $cached whether to use cached data
262 *
263 * @return contents of info.xml, or null if info.xml cannot be retrieved or parsed
264 */
265 private function grabRemoteInfoFile($key, $cached = FALSE) {
266 $filename = $this->cacheDir . DIRECTORY_SEPARATOR . $key . '.xml';
267 $url = $this->getRepositoryUrl() . '/' . $key . '.xml';
268
269 if (!$cached || !file_exists($filename)) {
270 file_put_contents($filename, file_get_contents($url));
271 }
272
273 if (file_exists($filename)) {
274 $contents = file_get_contents($filename);
275
276 //parse just in case
277 $check = simplexml_load_string($contents);
278
279 if (!$check) {
280 foreach (libxml_get_errors() as $error) {
281 CRM_Core_Error::debug('xmlError', $error);
282 }
283 return;
284 }
285
286 return $contents;
287 }
288 }
289
290 private function getTsPath() {
291 return $this->cacheDir . DIRECTORY_SEPARATOR . 'timestamp.txt';
292 }
293
294 /**
295 * A dummy function required for suppressing download errors
296 */
297 public static function downloadError($errorNumber, $errorString) {
298 return;
299 }
300
301 }