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