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