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