Support for WebP images (#3861)
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / acp / page / SystemCheckPage.class.php
1 <?php
2 namespace wcf\acp\page;
3 use wcf\data\application\Application;
4 use wcf\data\object\type\ObjectTypeCache;
5 use wcf\page\AbstractPage;
6 use wcf\system\database\util\PreparedStatementConditionBuilder;
7 use wcf\system\exception\SystemException;
8 use wcf\system\search\SearchIndexManager;
9 use wcf\system\WCF;
10 use wcf\util\FileUtil;
11
12 /**
13 * @author Alexander Ebert
14 * @copyright 2001-2020 WoltLab GmbH
15 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
16 * @package WoltLabSuite\Core\Acp\Page
17 * @since 5.2
18 */
19 class SystemCheckPage extends AbstractPage {
20 /**
21 * @inheritDoc
22 */
23 public $activeMenuItem = 'wcf.acp.menu.link.systemCheck';
24
25 /**
26 * @inheritDoc
27 */
28 public $neededPermissions = ['admin.configuration.package.canInstallPackage'];
29
30 /**
31 * A list of directories that need to be writable at all times, grouped by their application
32 * identifier. Only the directory itself is checked, unless the path ends with `/*`.
33 * @var string[][]
34 */
35 public $directories = [
36 'wcf' => [
37 '/',
38 '/acp/style',
39 '/acp/templates/compiled',
40 '/attachments/*',
41 '/cache',
42 '/images/*',
43 '/language',
44 '/log',
45 '/media_files/*',
46 '/sitemaps',
47 '/style',
48 '/templates/compiled',
49 '/tmp',
50 ],
51 ];
52
53 public $mysqlVersions = [
54 'mysql' => [
55 '5' => '5.7.31',
56 '8' => '8.0.19',
57 ],
58 'mariadb' => [
59 '10' => '10.1.44',
60 ],
61 ];
62
63 public $phpExtensions = [
64 'mbstring',
65 'libxml',
66 'dom',
67 'zlib',
68 'pdo',
69 'pdo_mysql',
70 'json',
71 'pcre',
72 'gd',
73 'hash',
74 'exif',
75 'ctype',
76 ];
77
78 public $phpMemoryLimit = 128;
79
80 public $phpVersions = [
81 'minimum' => '7.2.24',
82 'sufficient' => ['7.2'],
83 'recommended' => ['7.3', '7.4', '8.0'],
84 ];
85
86 public $foreignKeys = [
87 'wcf'. WCF_N .'_user' => [
88 'avatarID' => [
89 'referenceTable' => 'wcf'. WCF_N .'_user_avatar',
90 'referenceColumn' => 'avatarID',
91 ],
92 ],
93 'wcf'. WCF_N .'_comment' => [
94 'userID' => [
95 'referenceTable' => 'wcf'. WCF_N .'_user',
96 'referenceColumn' => 'userID',
97 ],
98 'objectTypeID' => [
99 'referenceTable' => 'wcf'. WCF_N .'_object_type',
100 'referenceColumn' => 'objectTypeID',
101 ],
102 ],
103 'wcf'. WCF_N .'_moderation_queue' => [
104 'objectTypeID' => [
105 'referenceTable' => 'wcf'. WCF_N .'_object_type',
106 'referenceColumn' => 'objectTypeID',
107 ],
108 'assignedUserID' => [
109 'referenceTable' => 'wcf'. WCF_N .'_user',
110 'referenceColumn' => 'userID',
111 ],
112 'userID' => [
113 'referenceTable' => 'wcf'. WCF_N .'_user',
114 'referenceColumn' => 'userID',
115 ],
116 ],
117 ];
118
119 public $results = [
120 'directories' => [],
121 'mysql' => [
122 'innodb' => false,
123 'mariadb' => false,
124 'result' => false,
125 'version' => '0.0.0',
126 'foreignKeys' => false,
127 'searchEngine' => [
128 'result' => false,
129 'incorrectTables' => [],
130 ],
131 ],
132 'php' => [
133 'gd' => [
134 'jpeg' => false,
135 'png' => false,
136 'webp' => false,
137 'result' => false,
138 ],
139 'extension' => [],
140 'memoryLimit' => [
141 'required' => '0',
142 'result' => false,
143 'value' => '0',
144 ],
145 'sha256' => false,
146 'version' => [
147 'result' => 'unsupported',
148 'value' => '0.0.0',
149 ],
150 ],
151 'status' => [
152 'directories' => false,
153 'mysql' => false,
154 'php' => false,
155 ],
156 ];
157
158 /**
159 * indicates that this page is only accessible to owners in enterprise mode
160 */
161 const BLACKLISTED_IN_ENTERPRISE_MODE = true;
162
163 /**
164 * @inheritDoc
165 */
166 public function readData() {
167 parent::readData();
168
169 if (IMAGE_ADAPTER_TYPE === 'imagick' && !in_array('imagick', $this->phpExtensions)) {
170 $this->phpExtensions[] = 'imagick';
171 }
172
173 if (CACHE_SOURCE_TYPE === 'memcached' && !in_array('memcached', $this->phpExtensions)) {
174 $this->phpExtensions[] = 'memcached';
175 }
176
177 if (CACHE_SOURCE_TYPE === 'redis' && !in_array('redis', $this->phpExtensions)) {
178 $this->phpExtensions[] = 'redis';
179 }
180
181 $this->validateMysql();
182 $this->validatePhpExtensions();
183 $this->validatePhpMemoryLimit();
184 $this->validatePhpVersion();
185 $this->validatePhpGdSupport();
186 $this->validateWritableDirectories();
187 }
188
189 /**
190 * @inheritDoc
191 */
192 public function assignVariables() {
193 parent::assignVariables();
194
195 WCF::getTPL()->assign([
196 'mysqlVersions' => $this->mysqlVersions,
197 'phpExtensions' => $this->phpExtensions,
198 'phpMemoryLimit' => $this->phpMemoryLimit,
199 'phpVersions' => $this->phpVersions,
200 'results' => $this->results,
201 ]);
202 }
203
204 protected function validateMysql() {
205 // check sql version
206 $sqlVersion = WCF::getDB()->getVersion();
207 $compareSQLVersion = preg_replace('/^(\d+\.\d+\.\d+).*$/', '\\1', $sqlVersion);
208 // Do not use the "raw" version, it usually contains a lot of noise.
209 $this->results['mysql']['version'] = $compareSQLVersion;
210 if (stripos($sqlVersion, 'MariaDB') !== false) {
211 $this->results['mysql']['mariadb'] = true;
212
213 $this->results['mysql']['result'] = (version_compare($compareSQLVersion, $this->mysqlVersions['mariadb']['10']) >= 0);
214 }
215 else {
216 if ($compareSQLVersion[0] === '5') {
217 $this->results['mysql']['result'] = (version_compare($compareSQLVersion, $this->mysqlVersions['mysql']['5']) >= 0);
218 }
219 else {
220 $this->results['mysql']['result'] = (version_compare($compareSQLVersion, $this->mysqlVersions['mysql']['8']) >= 0);
221 }
222 }
223
224 // check innodb support
225 $sql = "SHOW ENGINES";
226 $statement = WCF::getDB()->prepareStatement($sql);
227 $statement->execute();
228 while ($row = $statement->fetchArray()) {
229 if ($row['Engine'] == 'InnoDB' && in_array($row['Support'], ['DEFAULT', 'YES'])) {
230 $this->results['mysql']['innodb'] = true;
231 break;
232 }
233 }
234
235 // validate foreign keys
236 $expectedForeignKeyCount = 0;
237 $conditionBuilder = new PreparedStatementConditionBuilder(true, 'OR');
238 foreach ($this->foreignKeys as $table => $keys) {
239 foreach ($keys as $column => $reference) {
240 $innerConditionBuilder = new PreparedStatementConditionBuilder(false);
241 $innerConditionBuilder->add('REFERENCED_TABLE_SCHEMA = ?', [WCF::getDB()->getDatabaseName()]);
242 $innerConditionBuilder->add('REFERENCED_TABLE_NAME = ?', [$reference['referenceTable']]);
243 $innerConditionBuilder->add('REFERENCED_COLUMN_NAME = ?', [$reference['referenceColumn']]);
244 $innerConditionBuilder->add('TABLE_NAME = ?', [$table]);
245 $innerConditionBuilder->add('COLUMN_NAME = ?', [$column]);
246
247 $conditionBuilder->add('('. $innerConditionBuilder .')', $innerConditionBuilder->getParameters());
248
249 $expectedForeignKeyCount++;
250 }
251 }
252
253 $sql = "SELECT COUNT(*)
254 FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
255 ". $conditionBuilder;
256 $statement = WCF::getDB()->prepareStatement($sql);
257 $statement->execute($conditionBuilder->getParameters());
258
259 $this->results['mysql']['foreignKeys'] = $statement->fetchSingleColumn() == $expectedForeignKeyCount;
260
261 // check search engine tables
262 $objectTypes = ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.searchableObjectType');
263 $tableNames = [];
264 foreach ($objectTypes as $objectType) {
265 $tableNames[] = SearchIndexManager::getTableName($objectType->objectType);
266 }
267 $conditionBuilder = new PreparedStatementConditionBuilder(true);
268 $conditionBuilder->add('TABLE_NAME IN (?)', [$tableNames]);
269 $conditionBuilder->add('TABLE_SCHEMA = ?', [WCF::getDB()->getDatabaseName()]);
270
271 $sql = "SELECT TABLE_NAME, ENGINE
272 FROM INFORMATION_SCHEMA.TABLES
273 ". $conditionBuilder;
274 $statement = WCF::getDB()->prepareStatement($sql);
275 $statement->execute($conditionBuilder->getParameters());
276
277 while ($row = $statement->fetchArray()) {
278 if ($row['ENGINE'] !== 'InnoDB') {
279 $this->results['mysql']['searchEngine']['incorrectTables'][$row['TABLE_NAME']] = $row['ENGINE'];
280 }
281 }
282
283 $this->results['mysql']['searchEngine']['result'] = empty($this->results['mysql']['searchEngine']['incorrectTables']);
284
285 if ($this->results['mysql']['result'] && $this->results['mysql']['innodb'] && $this->results['mysql']['foreignKeys'] && $this->results['mysql']['searchEngine']['result']) {
286 $this->results['status']['mysql'] = true;
287 }
288 }
289
290 protected function validatePhpExtensions() {
291 foreach ($this->phpExtensions as $phpExtension) {
292 $result = extension_loaded($phpExtension);
293 if (!$result) {
294 $this->results['php']['extension'][] = $phpExtension;
295 }
296 }
297
298 if (extension_loaded('hash')) {
299 $this->results['php']['sha256'] = in_array('sha256', hash_algos());
300 }
301
302 $this->results['status']['php'] = empty($this->results['php']['extension']) && $this->results['php']['sha256'];
303 }
304
305 protected function validatePhpMemoryLimit() {
306 $this->results['php']['memoryLimit']['required'] = $this->phpMemoryLimit . 'M';
307
308 $memoryLimit = ini_get('memory_limit');
309
310 // Memory is not limited through PHP.
311 if ($memoryLimit == -1) {
312 $this->results['php']['memoryLimit']['value'] = "\u{221E}";
313 $this->results['php']['memoryLimit']['result'] = true;
314 }
315 else {
316 // Completely numeric, PHP assumes this to be a value in bytes.
317 if (is_numeric($memoryLimit)) {
318 $memoryLimit = $memoryLimit / 1024 / 1024;
319
320 $this->results['php']['memoryLimit']['value'] = $memoryLimit . 'M';
321 $this->results['php']['memoryLimit']['result'] = ($memoryLimit >= $this->phpMemoryLimit);
322 }
323 else {
324 // PHP supports the 'K', 'M' and 'G' shorthand notations.
325 if (preg_match('~^(\d+)([KMG])$~', $memoryLimit, $matches)) {
326 switch ($matches[2]) {
327 case 'K':
328 $memoryLimit = $matches[1] / 1024;
329
330 $this->results['php']['memoryLimit']['value'] = $memoryLimit . 'M';
331 $this->results['php']['memoryLimit']['result'] = ($memoryLimit >= $this->phpMemoryLimit);
332 break;
333
334 case 'M':
335 $this->results['php']['memoryLimit']['value'] = $memoryLimit;
336 $this->results['php']['memoryLimit']['result'] = ($matches[1] >= $this->phpMemoryLimit);
337 break;
338
339 case 'G':
340 $this->results['php']['memoryLimit']['value'] = $memoryLimit;
341 $this->results['php']['memoryLimit']['result'] = ($matches[1] * 1024 >= $this->phpMemoryLimit);
342 break;
343
344 default:
345 $this->results['php']['memoryLimit']['value'] = $memoryLimit;
346 $this->results['php']['memoryLimit']['result'] = false;
347 return;
348 }
349 }
350 }
351 }
352
353 $this->results['status']['php'] = $this->results['status']['php'] && $this->results['php']['memoryLimit']['result'];
354 }
355
356 protected function validatePhpVersion() {
357 $phpVersion = phpversion();
358 $comparePhpVersion = preg_replace('/^(\d+\.\d+\.\d+).*$/', '\\1', $phpVersion);
359 // Do not use the "raw" version, it usually contains a lot of noise.
360 $this->results['php']['version']['value'] = $comparePhpVersion;
361 if (version_compare($comparePhpVersion, $this->phpVersions['minimum']) >= 0) {
362 $majorMinor = preg_replace('~^(\d+\.\d+).*$~', '\\1', $phpVersion);
363 foreach (['recommended', 'sufficient'] as $type) {
364 foreach ($this->phpVersions[$type] as $version) {
365 if ($majorMinor === $version) {
366 $this->results['php']['version']['result'] = $type;
367 break 2;
368 }
369 }
370 }
371 }
372 else {
373 $this->results['php']['version']['result'] = 'unsupported';
374 }
375
376 $this->results['status']['php'] = $this->results['status']['php'] && ($this->results['php']['version']['result'] !== 'unsupported');
377 }
378
379 protected function validatePhpGdSupport() {
380 if (!function_exists('\gd_info')) {
381 $this->results['status']['php'] = false;
382 return;
383 }
384
385 $gdInfo = \gd_info();
386 $this->results['php']['gd']['jpeg'] = !empty($gdInfo['JPEG Support']);
387 $this->results['php']['gd']['png'] = !empty($gdInfo['PNG Support']);
388 $this->results['php']['gd']['webp'] = !empty($gdInfo['WebP Support']);
389
390 $this->results['php']['gd']['result'] = $this->results['php']['gd']['jpeg']
391 && $this->results['php']['gd']['png']
392 && $this->results['php']['gd']['webp'];
393
394 $this->results['status']['php'] = $this->results['status']['php'] && $this->results['php']['gd']['result'];
395 }
396
397 protected function validateWritableDirectories() {
398 foreach ($this->directories as $abbreviation => $directories) {
399 $basePath = Application::getDirectory($abbreviation);
400 foreach ($directories as $directory) {
401 $recursive = false;
402 if (preg_match('~(.*)/\*$~', $directory, $matches)) {
403 $recursive = true;
404 $directory = $matches[1];
405 }
406
407 $path = $basePath . FileUtil::removeLeadingSlash(FileUtil::addTrailingSlash($directory));
408 if ($this->checkDirectory($path) && $recursive) {
409 $rdi = new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS);
410 $it = new \RecursiveIteratorIterator($rdi, \RecursiveIteratorIterator::SELF_FIRST);
411 /** @var \SplFileInfo $item */
412 foreach ($it as $item) {
413 if ($item->isDir()) {
414 $this->makeDirectoryWritable($item->getPathname());
415 }
416 }
417 }
418 }
419 }
420
421 $this->results['status']['directories'] = empty($this->results['directories']);
422 }
423
424 protected function checkDirectory($path) {
425 if (!$this->createDirectoryIfNotExists($path)) {
426 $this->results['directories'][] = FileUtil::unifyDirSeparator($path);
427 return false;
428 }
429
430 return $this->makeDirectoryWritable($path);
431 }
432
433 protected function createDirectoryIfNotExists($path) {
434 if (!file_exists($path) && !FileUtil::makePath($path)) {
435 // FileUtil::makePath() returns false if either the directory cannot be created
436 // or if it cannot be made writable.
437 if (!file_exists($path)) {
438 return false;
439 }
440 }
441
442 return true;
443 }
444
445 protected function makeDirectoryWritable($path) {
446 try {
447 FileUtil::makeWritable($path);
448 }
449 catch (SystemException $e) {
450 $this->results['directories'][] = FileUtil::unifyDirSeparator($path);
451 return false;
452 }
453
454 return true;
455 }
456 }