Merge pull request #23939 from civicrm/5.51
[civicrm-core.git] / CRM / Extension / Browser.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
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 |
9 +--------------------------------------------------------------------+
10 */
11
12 use GuzzleHttp\Exception\GuzzleException;
13
14 /**
15 * This class glues together the various parts of the extension
16 * system.
17 *
18 * @package CRM
19 * @copyright CiviCRM LLC https://civicrm.org/licensing
20 */
21 class CRM_Extension_Browser {
22
23 /**
24 * An URL for public extensions repository.
25 *
26 * Note: This default is now handled through setting/*.php.
27 *
28 * @deprecated
29 */
30 const DEFAULT_EXTENSIONS_REPOSITORY = 'https://civicrm.org/extdir/ver={ver}|cms={uf}';
31
32 /**
33 * Relative path below remote repository URL for single extensions file.
34 */
35 const SINGLE_FILE_PATH = '/single';
36
37 /**
38 * Timeout for when the connection or the server is slow
39 */
40 const CHECK_TIMEOUT = 5;
41
42 /**
43 * @var GuzzleHttp\Client
44 */
45 protected $guzzleClient;
46
47 /**
48 * @return \GuzzleHttp\Client
49 */
50 public function getGuzzleClient(): \GuzzleHttp\Client {
51 return $this->guzzleClient ?? new \GuzzleHttp\Client();
52 }
53
54 /**
55 * @param \GuzzleHttp\Client $guzzleClient
56 */
57 public function setGuzzleClient(\GuzzleHttp\Client $guzzleClient) {
58 $this->guzzleClient = $guzzleClient;
59 }
60
61 /**
62 * @param string $repoUrl
63 * URL of the remote repository.
64 * @param string $indexPath
65 * Relative path of the 'index' file within the repository.
66 */
67 public function __construct($repoUrl, $indexPath) {
68 $this->repoUrl = $repoUrl;
69 $this->indexPath = empty($indexPath) ? self::SINGLE_FILE_PATH : $indexPath;
70 }
71
72 /**
73 * Determine whether the system policy allows downloading new extensions.
74 *
75 * This is reflection of *policy* and *intent*; it does not indicate whether
76 * the browser will actually *work*. For that, see checkRequirements().
77 *
78 * @return bool
79 */
80 public function isEnabled() {
81 return (FALSE !== $this->getRepositoryUrl());
82 }
83
84 /**
85 * @return string
86 */
87 public function getRepositoryUrl() {
88 return $this->repoUrl;
89 }
90
91 /**
92 * Refresh the cache of remotely-available extensions.
93 */
94 public function refresh() {
95 \Civi::cache('extension_browser')->flush();
96 }
97
98 /**
99 * Determine whether downloading is supported.
100 *
101 * @return array
102 * List of error messages; empty if OK.
103 */
104 public function checkRequirements() {
105 if (!$this->isEnabled()) {
106 return [];
107 }
108
109 // We used to check for the cache filesystem permissions, but it is now stored in DB
110 // If no new requirements have come up, consider removing this function after CiviCRM 5.60.
111 // The tests may need to be updated as well (tests/phpunit/CRM/Extension/BrowserTest.php).
112 $errors = [];
113 return $errors;
114 }
115
116 /**
117 * Get a list of all available extensions.
118 *
119 * @return CRM_Extension_Info[]
120 * ($key => CRM_Extension_Info)
121 */
122 public function getExtensions() {
123 if (!$this->isEnabled() || count($this->checkRequirements())) {
124 return [];
125 }
126
127 $exts = [];
128 $remote = $this->_discoverRemote();
129
130 if (is_array($remote)) {
131 foreach ($remote as $dc => $e) {
132 $exts[$e->key] = $e;
133 }
134 }
135
136 return $exts;
137 }
138
139 /**
140 * Get a description of a particular extension.
141 *
142 * @param string $key
143 * Fully-qualified extension name.
144 *
145 * @return CRM_Extension_Info|NULL
146 */
147 public function getExtension($key) {
148 // TODO optimize performance -- we don't need to fetch/cache the entire repo
149 $exts = $this->getExtensions();
150 if (array_key_exists($key, $exts)) {
151 return $exts[$key];
152 }
153 else {
154 return NULL;
155 }
156 }
157
158 /**
159 * @return CRM_Extension_Info[]
160 * @throws CRM_Extension_Exception_ParseException
161 */
162 private function _discoverRemote() {
163 $remotes = json_decode($this->grabCachedJson(), TRUE);
164 $this->_remotesDiscovered = [];
165
166 foreach ((array) $remotes as $id => $xml) {
167 $ext = CRM_Extension_Info::loadFromString($xml);
168 $this->_remotesDiscovered[] = $ext;
169 }
170
171 return $this->_remotesDiscovered;
172 }
173
174 /**
175 * Loads the extensions data from the cache file. If it is empty
176 * or doesn't exist, try fetching from remote instead.
177 *
178 * @return string
179 */
180 private function grabCachedJson() {
181 $cacheKey = $this->getCacheKey();
182 $json = \Civi::cache('extension_browser')->get($cacheKey);
183 if ($json === NULL) {
184 $json = $this->grabRemoteJson();
185 }
186 return $json;
187 }
188
189 /**
190 * Connects to public server and grabs the list of publicly available
191 * extensions.
192 *
193 * @return string
194 * @throws \CRM_Extension_Exception
195 */
196 private function grabRemoteJson() {
197 set_error_handler(array('CRM_Extension_Browser', 'downloadError'));
198
199 if (FALSE === $this->getRepositoryUrl()) {
200 // don't check if the user has configured civi not to check an external
201 // url for extensions. See CRM-10575.
202 return '';
203 }
204
205 $url = $this->getRepositoryUrl() . $this->indexPath;
206 $client = $this->getGuzzleClient();
207 try {
208 $response = $client->request('GET', $url, [
209 'timeout' => \Civi::settings()->get('http_timeout'),
210 ]);
211 }
212 catch (GuzzleException $e) {
213 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', [1 => $this->getRepositoryUrl()]), 'connection_error');
214 }
215 restore_error_handler();
216
217 if ($response->getStatusCode() !== 200) {
218 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', [1 => $this->getRepositoryUrl()]), 'connection_error');
219 }
220
221 $json = $response->getBody()->getContents();
222 $cacheKey = $this->getCacheKey();
223 \Civi::cache('extension_browser')->set($cacheKey, $json);
224 return $json;
225 }
226
227 /**
228 * Returns a cache key based on the repository URL, which can be updated
229 * by admins in civicrm.settings.php or passed as a command-line option to cv.
230 */
231 private function getCacheKey() {
232 return 'extdir_' . md5($this->getRepositoryUrl());
233 }
234
235 /**
236 * A dummy function required for suppressing download errors.
237 *
238 * @param $errorNumber
239 * @param $errorString
240 */
241 public static function downloadError($errorNumber, $errorString) {
242 }
243
244 }