Merge pull request #7030 from eileenmcnaughton/tests
[civicrm-core.git] / CRM / Extension / Browser.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | CiviCRM version 4.7 |
5 +--------------------------------------------------------------------+
6 | Copyright CiviCRM LLC (c) 2004-2015 |
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-2015
34 * $Id$
35 *
36 */
37 class CRM_Extension_Browser {
38
39 /**
40 * An URL for public extensions repository.
41 */
42 const DEFAULT_EXTENSIONS_REPOSITORY = 'https://civicrm.org/extdir/ver={ver}|cms={uf}';
43
44 /**
45 * Relative path below remote repository URL for single extensions file.
46 */
47 const SINGLE_FILE_PATH = '/single';
48
49 /**
50 * The name of the single JSON extension cache file.
51 */
52 const CACHE_JSON_FILE = 'extensions.json';
53
54 /**
55 * @param string $repoUrl
56 * URL of the remote repository.
57 * @param string $indexPath
58 * Relative path of the 'index' file within the repository.
59 * @param string $cacheDir
60 * Local path in which to cache files.
61 */
62 public function __construct($repoUrl, $indexPath, $cacheDir) {
63 $this->repoUrl = $repoUrl;
64 $this->cacheDir = $cacheDir;
65 $this->indexPath = empty($indexPath) ? self::SINGLE_FILE_PATH : $indexPath;
66 if ($cacheDir && !file_exists($cacheDir) && is_dir(dirname($cacheDir)) && is_writable(dirname($cacheDir))) {
67 CRM_Utils_File::createDir($cacheDir, FALSE);
68 }
69 }
70
71 /**
72 * Determine whether the system policy allows downloading new extensions.
73 *
74 * This is reflection of *policy* and *intent*; it does not indicate whether
75 * the browser will actually *work*. For that, see checkRequirements().
76 *
77 * @return bool
78 */
79 public function isEnabled() {
80 return (FALSE !== $this->getRepositoryUrl());
81 }
82
83 /**
84 * @return string
85 */
86 public function getRepositoryUrl() {
87 return $this->repoUrl;
88 }
89
90 /**
91 * Refresh the cache of remotely-available extensions.
92 */
93 public function refresh() {
94 $file = $this->getTsPath();
95 if (file_exists($file)) {
96 unlink($file);
97 }
98 }
99
100 /**
101 * Determine whether downloading is supported.
102 *
103 * @return array
104 * List of error messages; empty if OK.
105 */
106 public function checkRequirements() {
107 if (!$this->isEnabled()) {
108 return array();
109 }
110
111 $errors = array();
112
113 if (!$this->cacheDir || !is_dir($this->cacheDir) || !is_writable($this->cacheDir)) {
114 $civicrmDestination = urlencode(CRM_Utils_System::url('civicrm/admin/extensions', 'reset=1'));
115 $url = CRM_Utils_System::url('civicrm/admin/setting/path', "reset=1&civicrmDestination=${civicrmDestination}");
116 $errors[] = array(
117 'title' => ts('Directory Unwritable'),
118 '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/>',
119 array(
120 1 => $this->cacheDir,
121 2 => $url,
122 )
123 ),
124 );
125 }
126
127 return $errors;
128 }
129
130 /**
131 * Get a list of all available extensions.
132 *
133 * @return array
134 * ($key => CRM_Extension_Info)
135 */
136 public function getExtensions() {
137 if (!$this->isEnabled() || count($this->checkRequirements())) {
138 return array();
139 }
140
141 $exts = array();
142
143 $remote = $this->_discoverRemote();
144 if (is_array($remote)) {
145 foreach ($remote as $dc => $e) {
146 $exts[$e->key] = $e;
147 }
148 }
149
150 return $exts;
151 }
152
153 /**
154 * Get a description of a particular extension.
155 *
156 * @param string $key
157 * Fully-qualified extension name.
158 *
159 * @return CRM_Extension_Info|NULL
160 */
161 public function getExtension($key) {
162 // TODO optimize performance -- we don't need to fetch/cache the entire repo
163 $exts = $this->getExtensions();
164 if (array_key_exists($key, $exts)) {
165 return $exts[$key];
166 }
167 else {
168 // throw new CRM_Extension_Exception("Unknown remote extension: $key");
169 return NULL;
170 }
171 }
172
173 /**
174 * @return array
175 * @throws CRM_Extension_Exception_ParseException
176 */
177 private function _discoverRemote() {
178 $tsPath = $this->getTsPath();
179 $timestamp = FALSE;
180
181 if (file_exists($tsPath)) {
182 $timestamp = file_get_contents($tsPath);
183 }
184
185 // 3 minutes ago for now
186 $outdated = (int) $timestamp < (time() - 180) ? TRUE : FALSE;
187
188 if (!$timestamp || $outdated) {
189 $remotes = json_decode($this->grabRemoteJson(), TRUE);
190 }
191 else {
192 $remotes = json_decode($this->grabCachedJson(), TRUE);
193 }
194
195 $this->_remotesDiscovered = array();
196 foreach ($remotes as $id => $xml) {
197 $ext = CRM_Extension_Info::loadFromString($xml);
198 $this->_remotesDiscovered[] = $ext;
199 }
200
201 if (file_exists(dirname($tsPath))) {
202 file_put_contents($tsPath, (string) time());
203 }
204
205 return $this->_remotesDiscovered;
206 }
207
208 /**
209 * Loads the extensions data from the cache file. If it is empty
210 * or doesn't exist, try fetching from remote instead.
211 *
212 * @return string
213 */
214 private function grabCachedJson() {
215 $filename = $this->cacheDir . DIRECTORY_SEPARATOR . self::CACHE_JSON_FILE;
216 $json = file_get_contents($filename);
217 if (empty($json)) {
218 $json = $this->grabRemoteJson();
219 }
220 return $json;
221 }
222
223 /**
224 * Connects to public server and grabs the list of publicly available
225 * extensions.
226 *
227 * @return string
228 * @throws \CRM_Extension_Exception
229 */
230 private function grabRemoteJson() {
231
232 ini_set('default_socket_timeout', CRM_Utils_VersionCheck::CHECK_TIMEOUT);
233 set_error_handler(array('CRM_Extension_Browser', 'downloadError'));
234
235 if (!ini_get('allow_url_fopen')) {
236 ini_set('allow_url_fopen', 1);
237 }
238
239 if (FALSE === $this->getRepositoryUrl()) {
240 // don't check if the user has configured civi not to check an external
241 // url for extensions. See CRM-10575.
242 return array();
243 }
244
245 $filename = $this->cacheDir . DIRECTORY_SEPARATOR . self::CACHE_JSON_FILE;
246 $url = $this->getRepositoryUrl() . $this->indexPath;
247 $status = CRM_Utils_HttpClient::singleton()->fetch($url, $filename);
248
249 ini_restore('allow_url_fopen');
250 ini_restore('default_socket_timeout');
251
252 restore_error_handler();
253
254 if ($status !== CRM_Utils_HttpClient::STATUS_OK) {
255 throw new CRM_Extension_Exception(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>.', array(1 => $this->getRepositoryUrl())), 'connection_error');
256 }
257
258 // Don't call grabCachedJson here, that would risk infinite recursion
259 return file_get_contents($filename);
260 }
261
262 /**
263 * @return string
264 */
265 private function getTsPath() {
266 return $this->cacheDir . DIRECTORY_SEPARATOR . 'timestamp.txt';
267 }
268
269 /**
270 * A dummy function required for suppressing download errors.
271 *
272 * @param $errorNumber
273 * @param $errorString
274 */
275 public static function downloadError($errorNumber, $errorString) {
276 }
277
278 }