3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
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 +--------------------------------------------------------------------+
13 * This class glues together the various parts of the extension
17 * @copyright CiviCRM LLC https://civicrm.org/licensing
19 class CRM_Extension_Browser
{
22 * An URL for public extensions repository.
24 * Note: This default is now handled through setting/*.php.
28 const DEFAULT_EXTENSIONS_REPOSITORY
= 'https://civicrm.org/extdir/ver={ver}|cms={uf}';
31 * Relative path below remote repository URL for single extensions file.
33 const SINGLE_FILE_PATH
= '/single';
36 * The name of the single JSON extension cache file.
38 const CACHE_JSON_FILE
= 'extensions.json';
40 // timeout for when the connection or the server is slow
41 const CHECK_TIMEOUT
= 5;
44 * @var GuzzleHttp\Client
46 protected $guzzleClient;
49 * @return \GuzzleHttp\Client
51 public function getGuzzleClient(): \GuzzleHttp\Client
{
52 return $this->guzzleClient ??
new \GuzzleHttp\
Client();
56 * @param \GuzzleHttp\Client $guzzleClient
58 public function setGuzzleClient(\GuzzleHttp\Client
$guzzleClient) {
59 $this->guzzleClient
= $guzzleClient;
63 * @param string $repoUrl
64 * URL of the remote repository.
65 * @param string $indexPath
66 * Relative path of the 'index' file within the repository.
67 * @param string $cacheDir
68 * Local path in which to cache files.
70 public function __construct($repoUrl, $indexPath, $cacheDir) {
71 $this->repoUrl
= $repoUrl;
72 $this->cacheDir
= $cacheDir;
73 $this->indexPath
= empty($indexPath) ? self
::SINGLE_FILE_PATH
: $indexPath;
74 if ($cacheDir && !file_exists($cacheDir) && is_dir(dirname($cacheDir)) && is_writable(dirname($cacheDir))) {
75 CRM_Utils_File
::createDir($cacheDir, FALSE);
80 * Determine whether the system policy allows downloading new extensions.
82 * This is reflection of *policy* and *intent*; it does not indicate whether
83 * the browser will actually *work*. For that, see checkRequirements().
87 public function isEnabled() {
88 return (FALSE !== $this->getRepositoryUrl());
94 public function getRepositoryUrl() {
95 return $this->repoUrl
;
99 * Refresh the cache of remotely-available extensions.
101 public function refresh() {
102 $file = $this->getTsPath();
103 if (file_exists($file)) {
109 * Determine whether downloading is supported.
112 * List of error messages; empty if OK.
114 public function checkRequirements() {
115 if (!$this->isEnabled()) {
121 if (!$this->cacheDir ||
!is_dir($this->cacheDir
) ||
!is_writable($this->cacheDir
)) {
122 $civicrmDestination = urlencode(CRM_Utils_System
::url('civicrm/admin/extensions', 'reset=1'));
123 $url = CRM_Utils_System
::url('civicrm/admin/setting/path', "reset=1&civicrmDestination=${civicrmDestination}");
125 'title' => ts('Directory Unwritable'),
126 '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/>',
128 1 => $this->cacheDir
,
139 * Get a list of all available extensions.
141 * @return CRM_Extension_Info[]
142 * ($key => CRM_Extension_Info)
144 public function getExtensions() {
145 if (!$this->isEnabled() ||
count($this->checkRequirements())) {
151 $remote = $this->_discoverRemote();
152 if (is_array($remote)) {
153 foreach ($remote as $dc => $e) {
162 * Get a description of a particular extension.
165 * Fully-qualified extension name.
167 * @return CRM_Extension_Info|NULL
169 public function getExtension($key) {
170 // TODO optimize performance -- we don't need to fetch/cache the entire repo
171 $exts = $this->getExtensions();
172 if (array_key_exists($key, $exts)) {
181 * @return CRM_Extension_Info[]
182 * @throws CRM_Extension_Exception_ParseException
184 private function _discoverRemote() {
185 $tsPath = $this->getTsPath();
188 if (file_exists($tsPath)) {
189 $timestamp = file_get_contents($tsPath);
192 // 3 minutes ago for now
193 $outdated = (int) $timestamp < (time() - 180) ?
TRUE : FALSE;
195 if (!$timestamp ||
$outdated) {
196 $remotes = json_decode($this->grabRemoteJson(), TRUE);
199 $remotes = json_decode($this->grabCachedJson(), TRUE);
202 $this->_remotesDiscovered
= [];
203 foreach ((array) $remotes as $id => $xml) {
204 $ext = CRM_Extension_Info
::loadFromString($xml);
205 $this->_remotesDiscovered
[] = $ext;
208 if (file_exists(dirname($tsPath))) {
209 file_put_contents($tsPath, (string) time());
212 return $this->_remotesDiscovered
;
216 * Loads the extensions data from the cache file. If it is empty
217 * or doesn't exist, try fetching from remote instead.
221 private function grabCachedJson() {
222 $filename = $this->cacheDir
. DIRECTORY_SEPARATOR
. self
::CACHE_JSON_FILE
. '.' . md5($this->getRepositoryUrl());
224 if (file_exists($filename)) {
225 $json = file_get_contents($filename);
228 $json = $this->grabRemoteJson();
234 * Connects to public server and grabs the list of publicly available
238 * @throws \CRM_Extension_Exception
240 private function grabRemoteJson() {
241 set_error_handler(array('CRM_Extension_Browser', 'downloadError'));
243 if (FALSE === $this->getRepositoryUrl()) {
244 // don't check if the user has configured civi not to check an external
245 // url for extensions. See CRM-10575.
249 $filename = $this->cacheDir
. DIRECTORY_SEPARATOR
. self
::CACHE_JSON_FILE
. '.' . md5($this->getRepositoryUrl());
250 $url = $this->getRepositoryUrl() . $this->indexPath
;
252 $client = $this->getGuzzleClient();
253 $response = $client->request('GET', $url, ['sink' => $filename, 'timeout' => \Civi
::settings()->get('http_timeout')]);
254 restore_error_handler();
256 if ($response->getStatusCode() !== 200) {
257 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');
260 // Don't call grabCachedJson here, that would risk infinite recursion
261 return file_get_contents($filename);
267 private function getTsPath() {
268 return $this->cacheDir
. DIRECTORY_SEPARATOR
. 'timestamp.txt';
272 * A dummy function required for suppressing download errors.
274 * @param $errorNumber
275 * @param $errorString
277 public static function downloadError($errorNumber, $errorString) {