Merge pull request #23009 from seamuslee001/dev_core_3132
[civicrm-core.git] / CRM / Extension / Downloader.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 /**
13 * This class handles downloads of remotely-provided extensions
14 *
15 * @package CRM
16 * @copyright CiviCRM LLC https://civicrm.org/licensing
17 */
18 class CRM_Extension_Downloader {
19
20 /**
21 * @var GuzzleHttp\Client
22 */
23 protected $guzzleClient;
24
25 /**
26 * @return \GuzzleHttp\Client
27 */
28 public function getGuzzleClient(): \GuzzleHttp\Client {
29 return $this->guzzleClient ?? new \GuzzleHttp\Client();
30 }
31
32 /**
33 * @param \GuzzleHttp\Client $guzzleClient
34 */
35 public function setGuzzleClient(\GuzzleHttp\Client $guzzleClient) {
36 $this->guzzleClient = $guzzleClient;
37 }
38
39 /**
40 * @var CRM_Extension_Container_Basic
41 * The place where downloaded extensions are ultimately stored
42 */
43 public $container;
44
45 /**
46 * @var string
47 * Local path to a temporary data directory
48 */
49 public $tmpDir;
50
51 /**
52 * @param CRM_Extension_Manager $manager
53 * @param string $containerDir
54 * The place to store downloaded & extracted extensions.
55 * @param string $tmpDir
56 */
57 public function __construct(CRM_Extension_Manager $manager, $containerDir, $tmpDir) {
58 $this->manager = $manager;
59 $this->containerDir = $containerDir;
60 $this->tmpDir = $tmpDir;
61 }
62
63 /**
64 * Determine whether downloading is supported.
65 *
66 * @param \CRM_EXtension_Info $extensionInfo Optional info for (updated) extension
67 *
68 * @return array
69 * list of error messages; empty if OK
70 */
71 public function checkRequirements($extensionInfo = NULL) {
72 $errors = [];
73
74 if (!$this->containerDir || !is_dir($this->containerDir) || !is_writable($this->containerDir)) {
75 $civicrmDestination = urlencode(CRM_Utils_System::url('civicrm/admin/extensions', 'reset=1'));
76 $url = CRM_Utils_System::url('civicrm/admin/setting/path', "reset=1&civicrmDestination=${civicrmDestination}");
77 $errors[] = array(
78 'title' => ts('Directory Unwritable'),
79 'message' => ts("Your extensions directory is not set or is not writable. Click <a href='%1'>here</a> to set the extensions directory.",
80 array(
81 1 => $url,
82 )
83 ),
84 );
85 }
86
87 if (!class_exists('ZipArchive')) {
88 $errors[] = array(
89 'title' => ts('ZIP Support Required'),
90 'message' => ts('You will not be able to install extensions at this time because your installation of PHP does not support ZIP archives. Please ask your system administrator to install the standard PHP-ZIP extension.'),
91 );
92 }
93
94 if ($extensionInfo) {
95 $requiredExtensions = CRM_Extension_System::singleton()->getManager()->findInstallRequirements([$extensionInfo->key], $extensionInfo);
96 foreach ($requiredExtensions as $extension) {
97 if (CRM_Extension_System::singleton()->getManager()->getStatus($extension) !== CRM_Extension_Manager::STATUS_INSTALLED && $extension !== $extensionInfo->key) {
98 $requiredExtensionInfo = CRM_Extension_System::singleton()->getBrowser()->getExtension($extension);
99 $requiredExtensionInfoName = empty($requiredExtensionInfo->name) ? $extension : $requiredExtensionInfo->name;
100 $errors[] = [
101 'title' => ts('Missing Requirement: %1', [1 => $extension]),
102 'message' => ts('You will not be able to install/upgrade %1 until you have installed the %2 extension.', [1 => $extensionInfo->name, 2 => $requiredExtensionInfoName]),
103 ];
104 }
105 }
106 }
107
108 return $errors;
109 }
110
111 /**
112 * Install or upgrade an extension from a remote URL.
113 *
114 * @param string $key
115 * The name of the extension being installed.
116 * @param string $downloadUrl
117 * URL of a .zip file.
118 * @return bool
119 * TRUE for success
120 * @throws CRM_Extension_Exception
121 */
122 public function download($key, $downloadUrl) {
123 $filename = $this->tmpDir . DIRECTORY_SEPARATOR . $key . '.zip';
124 $destDir = $this->containerDir . DIRECTORY_SEPARATOR . $key;
125
126 if (!$downloadUrl) {
127 throw new CRM_Extension_Exception(ts('Cannot install this extension - downloadUrl is not set!'));
128 }
129
130 if (!$this->fetch($downloadUrl, $filename)) {
131 return FALSE;
132 }
133
134 $extractedZipPath = $this->extractFiles($key, $filename);
135 if (!$extractedZipPath) {
136 return FALSE;
137 }
138
139 if (!$this->validateFiles($key, $extractedZipPath)) {
140 return FALSE;
141 }
142
143 $this->manager->replace($extractedZipPath);
144
145 return TRUE;
146 }
147
148 /**
149 * Download the remote zipfile.
150 *
151 * @param string $remoteFile
152 * URL of a .zip file.
153 * @param string $localFile
154 * Path at which to store the .zip file.
155 * @return bool
156 * Whether the download was successful.
157 */
158 public function fetch($remoteFile, $localFile) {
159 $client = $this->getGuzzleClient();
160 $response = $client->request('GET', $remoteFile, ['sink' => $localFile, 'timeout' => \Civi::settings()->get('http_timeout')]);
161 if ($response->getStatusCode() === 200) {
162 return TRUE;
163 }
164 return FALSE;
165 }
166
167 /**
168 * Extract an extension from a zip file.
169 *
170 * @param string $key
171 * The name of the extension being installed; this usually matches the basedir in the .zip.
172 * @param string $zipFile
173 * The local path to a .zip file.
174 * @return string|FALSE
175 * zip file path
176 */
177 public function extractFiles($key, $zipFile) {
178 $config = CRM_Core_Config::singleton();
179
180 $zip = new ZipArchive();
181 $res = $zip->open($zipFile);
182 if ($res === TRUE) {
183 $zipSubDir = CRM_Utils_Zip::guessBasedir($zip, $key);
184 if ($zipSubDir === FALSE) {
185 CRM_Core_Session::setStatus(ts('Unable to extract the extension: bad directory structure'), '', 'error');
186 return FALSE;
187 }
188 $extractedZipPath = $this->tmpDir . DIRECTORY_SEPARATOR . $zipSubDir;
189 if (is_dir($extractedZipPath)) {
190 if (!CRM_Utils_File::cleanDir($extractedZipPath, TRUE, FALSE)) {
191 CRM_Core_Session::setStatus(ts('Unable to extract the extension: %1 cannot be cleared', array(1 => $extractedZipPath)), ts('Installation Error'), 'error');
192 return FALSE;
193 }
194 }
195 if (!$zip->extractTo($this->tmpDir)) {
196 CRM_Core_Session::setStatus(ts('Unable to extract the extension to %1.', array(1 => $this->tmpDir)), ts('Installation Error'), 'error');
197 return FALSE;
198 }
199 $zip->close();
200 }
201 else {
202 CRM_Core_Session::setStatus(ts('Unable to extract the extension.'), '', 'error');
203 return FALSE;
204 }
205
206 return $extractedZipPath;
207 }
208
209 /**
210 * Validate that $extractedZipPath contains valid for extension $key
211 *
212 * @param $key
213 * @param $extractedZipPath
214 *
215 * @return bool
216 * @throws CRM_Core_Exception
217 */
218 public function validateFiles($key, $extractedZipPath) {
219 $filename = $extractedZipPath . DIRECTORY_SEPARATOR . CRM_Extension_Info::FILENAME;
220 if (!is_readable($filename)) {
221 CRM_Core_Session::setStatus(ts('Failed reading data from %1 during installation', array(1 => $filename)), ts('Installation Error'), 'error');
222 return FALSE;
223 }
224
225 try {
226 $newInfo = CRM_Extension_Info::loadFromFile($filename);
227 }
228 catch (Exception $e) {
229 CRM_Core_Session::setStatus(ts('Failed reading data from %1 during installation', array(1 => $filename)), ts('Installation Error'), 'error');
230 return FALSE;
231 }
232
233 if ($newInfo->key != $key) {
234 throw new CRM_Core_Exception(ts('Cannot install - there are differences between extdir XML file and archive XML file!'));
235 }
236
237 return TRUE;
238 }
239
240 }