Resolve language item-related PIP GUI todos
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / package / PackageInstallationScheduler.class.php
1 <?php
2 namespace wcf\system\package;
3 use wcf\data\package\update\server\PackageUpdateServer;
4 use wcf\data\package\update\PackageUpdate;
5 use wcf\data\package\Package;
6 use wcf\data\package\PackageCache;
7 use wcf\system\database\util\PreparedStatementConditionBuilder;
8 use wcf\system\exception\HTTPUnauthorizedException;
9 use wcf\system\exception\SystemException;
10 use wcf\system\io\File;
11 use wcf\system\WCF;
12 use wcf\util\FileUtil;
13 use wcf\util\HTTPRequest;
14
15 /**
16 * Contains business logic related to preparation of package installations.
17 *
18 * @author Alexander Ebert
19 * @copyright 2001-2018 WoltLab GmbH
20 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
21 * @package WoltLabSuite\Core\System\Package
22 */
23 class PackageInstallationScheduler {
24 /**
25 * stack of package installations / updates
26 * @var array
27 */
28 protected $packageInstallationStack = [];
29
30 /**
31 * list of package update servers
32 * @var PackageUpdateServer[]
33 */
34 protected $packageUpdateServers = [];
35
36 /**
37 * list of packages to update or install
38 * @var array
39 */
40 protected $selectedPackages = [];
41
42 /**
43 * virtual package versions
44 * @var array
45 */
46 protected $virtualPackageVersions = [];
47
48 /**
49 * Creates a new instance of PackageInstallationScheduler
50 *
51 * @param string[] $selectedPackages
52 */
53 public function __construct(array $selectedPackages) {
54 $this->selectedPackages = $selectedPackages;
55 $this->packageUpdateServers = PackageUpdateServer::getActiveUpdateServers();
56 }
57
58 /**
59 * Builds the stack of package installations / updates.
60 *
61 * @param boolean $validateInstallInstructions
62 */
63 public function buildPackageInstallationStack($validateInstallInstructions = false) {
64 foreach ($this->selectedPackages as $package => $version) {
65 $this->tryToInstallPackage($package, $version, true, $validateInstallInstructions);
66 }
67 }
68
69 /**
70 * Tries to install a new package. Checks the virtual package version list.
71 *
72 * @param string $package package identifier
73 * @param string $minversion preferred package version
74 * @param boolean $installOldVersion true, if you want to install the package in the given minversion and not in the newest version
75 * @param boolean $validateInstallInstructions
76 */
77 protected function tryToInstallPackage($package, $minversion = '', $installOldVersion = false, $validateInstallInstructions = false) {
78 // check virtual package version
79 if (isset($this->virtualPackageVersions[$package])) {
80 if (!empty($minversion) && Package::compareVersion($this->virtualPackageVersions[$package], $minversion, '<')) {
81 $stackPosition = -1;
82 // remove installation of older version
83 foreach ($this->packageInstallationStack as $key => $value) {
84 if ($value['package'] == $package) {
85 $stackPosition = $key;
86 break;
87 }
88 }
89
90 // install newer version
91 $this->installPackage($package, ($installOldVersion ? $minversion : ''), $stackPosition, $validateInstallInstructions);
92 }
93 }
94 else {
95 // check if package is already installed
96 $packageID = PackageCache::getInstance()->getPackageID($package);
97 if ($packageID === null) {
98 // package is missing -> install
99 $this->installPackage($package, ($installOldVersion ? $minversion : ''), -1, $validateInstallInstructions);
100 }
101 else {
102 $package = PackageCache::getInstance()->getPackage($packageID);
103 if (!empty($minversion) && Package::compareVersion($package->packageVersion, $minversion, '<')) {
104 $this->updatePackage($packageID, ($installOldVersion ? $minversion : ''));
105 }
106 }
107 }
108 }
109
110 /**
111 * Installs a new package.
112 *
113 * @param string $package package identifier
114 * @param string $version package version
115 * @param integer $stackPosition
116 * @param boolean $validateInstallInstructions
117 */
118 protected function installPackage($package, $version = '', $stackPosition = -1, $validateInstallInstructions = false) {
119 // get package update versions
120 $packageUpdateVersions = PackageUpdateDispatcher::getInstance()->getPackageUpdateVersions($package, $version);
121
122 // resolve requirements
123 $this->resolveRequirements($packageUpdateVersions[0]['packageUpdateVersionID']);
124
125 // download package
126 $download = $this->downloadPackage($package, $packageUpdateVersions, $validateInstallInstructions);
127
128 // add to stack
129 $data = [
130 'packageName' => $packageUpdateVersions[0]['packageName'],
131 'packageVersion' => $packageUpdateVersions[0]['packageVersion'],
132 'package' => $package,
133 'packageID' => 0,
134 'archive' => $download,
135 'action' => 'install'
136 ];
137 if ($stackPosition == -1) $this->packageInstallationStack[] = $data;
138 else $this->packageInstallationStack[$stackPosition] = $data;
139
140 // update virtual versions
141 $this->virtualPackageVersions[$package] = $packageUpdateVersions[0]['packageVersion'];
142 }
143
144 /**
145 * Resolves the package requirements of an package update.
146 * Starts the installation or update to higher version of required packages.
147 *
148 * @param integer $packageUpdateVersionID
149 */
150 protected function resolveRequirements($packageUpdateVersionID) {
151 // resolve requirements
152 $requiredPackages = [];
153 $requirementsCache = [];
154 $sql = "SELECT *
155 FROM wcf".WCF_N."_package_update_requirement
156 WHERE packageUpdateVersionID = ?";
157 $statement = WCF::getDB()->prepareStatement($sql);
158 $statement->execute([$packageUpdateVersionID]);
159 while ($row = $statement->fetchArray()) {
160 $requiredPackages[] = $row['package'];
161 $requirementsCache[] = $row;
162 }
163
164 if (!empty($requiredPackages)) {
165 // find installed packages
166 $conditions = new PreparedStatementConditionBuilder();
167 $conditions->add("package IN (?)", [$requiredPackages]);
168
169 $installedPackages = [];
170 $sql = "SELECT packageID, package, packageVersion
171 FROM wcf".WCF_N."_package
172 ".$conditions;
173 $statement = WCF::getDB()->prepareStatement($sql);
174 $statement->execute($conditions->getParameters());
175 while ($row = $statement->fetchArray()) {
176 if (!isset($installedPackages[$row['package']])) $installedPackages[$row['package']] = [];
177 $installedPackages[$row['package']][$row['packageID']] = (isset($this->virtualPackageVersions[$row['packageID']]) ? $this->virtualPackageVersions[$row['packageID']] : $row['packageVersion']);
178 }
179
180 // check installed / missing packages
181 foreach ($requirementsCache as $row) {
182 if (isset($installedPackages[$row['package']])) {
183 // package already installed -> check version
184 // sort multiple instances by version number
185 uasort($installedPackages[$row['package']], [Package::class, 'compareVersion']);
186
187 $packageID = 0;
188 foreach ($installedPackages[$row['package']] as $packageID => $packageVersion) {
189 if (empty($row['minversion']) || Package::compareVersion($row['minversion'], $packageVersion, '<=')) {
190 continue 2;
191 }
192 }
193
194 // package version too low -> update necessary
195 $this->updatePackage($packageID, $row['minversion']);
196 }
197 else {
198 $this->tryToInstallPackage($row['package'], $row['minversion']);
199 }
200 }
201 }
202 }
203
204 /**
205 * Tries to download a package from available update servers.
206 *
207 * @param string $package package identifier
208 * @param array $packageUpdateVersions package update versions
209 * @param boolean $validateInstallInstructions
210 * @return string tmp filename of a downloaded package
211 * @throws PackageUpdateUnauthorizedException
212 * @throws SystemException
213 */
214 protected function downloadPackage($package, $packageUpdateVersions, $validateInstallInstructions = false) {
215 // get download from cache
216 if ($filename = $this->getCachedDownload($package, $packageUpdateVersions[0]['packageVersion'])) {
217 return $filename;
218 }
219
220 // download file
221 foreach ($packageUpdateVersions as $packageUpdateVersion) {
222 // get auth data
223 $authData = $this->getAuthData($packageUpdateVersion);
224
225 if ($packageUpdateVersion['filename']) {
226 $request = new HTTPRequest(
227 $packageUpdateVersion['filename'],
228 (!empty($authData) ? ['auth' => $authData] : []),
229 [
230 'apiVersion' => PackageUpdate::API_VERSION
231 ]
232 );
233 }
234 else {
235 // create request
236 $request = new HTTPRequest(
237 $this->packageUpdateServers[$packageUpdateVersion['packageUpdateServerID']]->getDownloadURL(),
238 (!empty($authData) ? ['auth' => $authData] : []),
239 [
240 'apiVersion' => PackageUpdate::API_VERSION,
241 'packageName' => $packageUpdateVersion['package'],
242 'packageVersion' => $packageUpdateVersion['packageVersion']
243 ]
244 );
245 }
246
247 try {
248 $request->execute();
249 }
250 catch (HTTPUnauthorizedException $e) {
251 throw new PackageUpdateUnauthorizedException($request, $this->packageUpdateServers[$packageUpdateVersion['packageUpdateServerID']], $packageUpdateVersion);
252 }
253
254 $response = $request->getReply();
255
256 // check response
257 if ($response['statusCode'] != 200) {
258 throw new SystemException(WCF::getLanguage()->getDynamicVariable('wcf.acp.package.error.downloadFailed', ['__downloadPackage' => $package]) . ' ('.$response['body'].')');
259 }
260
261 // write content to tmp file
262 $filename = FileUtil::getTemporaryFilename('package_');
263 $file = new File($filename);
264 $file->write($response['body']);
265 $file->close();
266 unset($response['body']);
267
268 // test package
269 $archive = new PackageArchive($filename);
270 $archive->openArchive();
271
272 // check install instructions
273 if ($validateInstallInstructions) {
274 $installInstructions = $archive->getInstallInstructions();
275 if (empty($installInstructions)) {
276 throw new SystemException("Package '" . $archive->getLocalizedPackageInfo('packageName') . "' (" . $archive->getPackageInfo('name') . ") does not contain valid installation instructions.");
277 }
278 }
279
280 $archive->getTar()->close();
281
282 // cache download in session
283 PackageUpdateDispatcher::getInstance()->cacheDownload($package, $packageUpdateVersion['packageVersion'], $filename);
284
285 return $filename;
286 }
287
288 return false;
289 }
290
291 /**
292 * Returns a list of excluded packages.
293 *
294 * @return array
295 */
296 public function getExcludedPackages() {
297 $excludedPackages = [];
298
299 if (!empty($this->packageInstallationStack)) {
300 $packageInstallations = [];
301 $packageIdentifier = [];
302 foreach ($this->packageInstallationStack as $packageInstallation) {
303 $packageInstallation['newVersion'] = ($packageInstallation['action'] == 'update' ? $packageInstallation['toVersion'] : $packageInstallation['packageVersion']);
304 $packageInstallations[] = $packageInstallation;
305 $packageIdentifier[] = $packageInstallation['package'];
306 }
307
308 // check exclusions of the new packages
309 // get package update ids
310 $conditions = new PreparedStatementConditionBuilder();
311 $conditions->add("package IN (?)", [$packageIdentifier]);
312
313 $sql = "SELECT packageUpdateID, package
314 FROM wcf".WCF_N."_package_update
315 ".$conditions;
316 $statement = WCF::getDB()->prepareStatement($sql);
317 $statement->execute($conditions->getParameters());
318 while ($row = $statement->fetchArray()) {
319 foreach ($packageInstallations as $key => $packageInstallation) {
320 if ($packageInstallation['package'] == $row['package']) {
321 $packageInstallations[$key]['packageUpdateID'] = $row['packageUpdateID'];
322 }
323 }
324 }
325
326 // get exclusions of the new packages
327 // build conditions
328 $conditions = '';
329 $statementParameters = [];
330 foreach ($packageInstallations as $packageInstallation) {
331 if (!empty($conditions)) $conditions .= ' OR ';
332 $conditions .= "(packageUpdateID = ? AND packageVersion = ?)";
333 $statementParameters[] = $packageInstallation['packageUpdateID'];
334 $statementParameters[] = $packageInstallation['newVersion'];
335 }
336
337 $sql = "SELECT package.*, package_update_exclusion.*,
338 package_update.packageUpdateID,
339 package_update.package
340 FROM wcf".WCF_N."_package_update_exclusion package_update_exclusion
341 LEFT JOIN wcf".WCF_N."_package_update_version package_update_version
342 ON (package_update_version.packageUpdateVersionID = package_update_exclusion.packageUpdateVersionID)
343 LEFT JOIN wcf".WCF_N."_package_update package_update
344 ON (package_update.packageUpdateID = package_update_version.packageUpdateID)
345 LEFT JOIN wcf".WCF_N."_package package
346 ON (package.package = package_update_exclusion.excludedPackage)
347 WHERE package_update_exclusion.packageUpdateVersionID IN (
348 SELECT packageUpdateVersionID
349 FROM wcf".WCF_N."_package_update_version
350 WHERE ".$conditions."
351 )
352 AND package.package IS NOT NULL";
353 $statement = WCF::getDB()->prepareStatement($sql);
354 $statement->execute($statementParameters);
355 while ($row = $statement->fetchArray()) {
356 foreach ($packageInstallations as $key => $packageInstallation) {
357 if ($packageInstallation['package'] == $row['package']) {
358 if (!isset($packageInstallations[$key]['excludedPackages'])) {
359 $packageInstallations[$key]['excludedPackages'] = [];
360 }
361 $packageInstallations[$key]['excludedPackages'][$row['excludedPackage']] = ['package' => $row['excludedPackage'], 'version' => $row['excludedPackageVersion']];
362
363 // check version
364 if (!empty($row['excludedPackageVersion'])) {
365 if (Package::compareVersion($row['packageVersion'], $row['excludedPackageVersion'], '<')) {
366 continue;
367 }
368 }
369
370 $excludedPackages[] = [
371 'package' => $row['package'],
372 'packageName' => $packageInstallations[$key]['packageName'],
373 'packageVersion' => $packageInstallations[$key]['newVersion'],
374 'action' => $packageInstallations[$key]['action'],
375 'conflict' => 'newPackageExcludesExistingPackage',
376 'existingPackage' => $row['excludedPackage'],
377 'existingPackageName' => WCF::getLanguage()->get($row['packageName']),
378 'existingPackageVersion' => $row['packageVersion']
379 ];
380 }
381 }
382 }
383
384 // check excluded packages of the existing packages
385 $conditions = new PreparedStatementConditionBuilder();
386 $conditions->add("excludedPackage IN (?)", [$packageIdentifier]);
387
388 $sql = "SELECT package.*, package_exclusion.*
389 FROM wcf".WCF_N."_package_exclusion package_exclusion
390 LEFT JOIN wcf".WCF_N."_package package
391 ON (package.packageID = package_exclusion.packageID)
392 ".$conditions;
393 $statement = WCF::getDB()->prepareStatement($sql);
394 $statement->execute($conditions->getParameters());
395 while ($row = $statement->fetchArray()) {
396 foreach ($packageInstallations as $key => $packageInstallation) {
397 if ($packageInstallation['package'] == $row['excludedPackage']) {
398 if (!empty($row['excludedPackageVersion'])) {
399 // check version
400 if (Package::compareVersion($packageInstallation['newVersion'], $row['excludedPackageVersion'], '<')) {
401 continue;
402 }
403
404 // search exclusing package in stack
405 foreach ($packageInstallations as $packageUpdate) {
406 if ($packageUpdate['packageID'] == $row['packageID']) {
407 // check new exclusions
408 if (!isset($packageUpdate['excludedPackages']) || !isset($packageUpdate['excludedPackages'][$row['excludedPackage']]) || (!empty($packageUpdate['excludedPackages'][$row['excludedPackage']]['version']) && Package::compareVersion($packageInstallation['newVersion'], $packageUpdate['excludedPackages'][$row['excludedPackage']]['version'], '<'))) {
409 continue 2;
410 }
411 }
412 }
413 }
414
415 $excludedPackages[] = [
416 'package' => $row['excludedPackage'],
417 'packageName' => $packageInstallation['packageName'],
418 'packageVersion' => $packageInstallation['newVersion'],
419 'action' => $packageInstallation['action'],
420 'conflict' => 'existingPackageExcludesNewPackage',
421 'existingPackage' => $row['package'],
422 'existingPackageName' => WCF::getLanguage()->get($row['packageName']),
423 'existingPackageVersion' => $row['packageVersion']
424 ];
425 }
426 }
427 }
428 }
429
430 return $excludedPackages;
431 }
432
433 /**
434 * Returns the stack of package installations.
435 *
436 * @return array
437 */
438 public function getPackageInstallationStack() {
439 return $this->packageInstallationStack;
440 }
441
442 /**
443 * Updates an existing package.
444 *
445 * @param integer $packageID
446 * @param string $version
447 */
448 protected function updatePackage($packageID, $version) {
449 // get package info
450 $package = PackageCache::getInstance()->getPackage($packageID);
451
452 // get current package version
453 $packageVersion = $package->packageVersion;
454 if (isset($this->virtualPackageVersions[$packageID])) {
455 $packageVersion = $this->virtualPackageVersions[$packageID];
456 // check virtual package version
457 if (Package::compareVersion($packageVersion, $version, '>=')) {
458 // virtual package version is greater than requested version
459 // skip package update
460 return;
461 }
462 }
463
464 // get highest version of the required major release
465 if (preg_match('/(\d+\.\d+\.)/', $version, $match)) {
466 $sql = "SELECT DISTINCT packageVersion
467 FROM wcf".WCF_N."_package_update_version
468 WHERE packageUpdateID IN (
469 SELECT packageUpdateID
470 FROM wcf".WCF_N."_package_update
471 WHERE package = ?
472 )
473 AND packageVersion LIKE ?";
474 $statement = WCF::getDB()->prepareStatement($sql);
475 $statement->execute([
476 $package->package,
477 $match[1].'%'
478 ]);
479 $packageVersions = $statement->fetchAll(\PDO::FETCH_COLUMN);
480
481 if (count($packageVersions) > 1) {
482 // sort by version number
483 usort($packageVersions, [Package::class, 'compareVersion']);
484
485 // get highest version
486 $version = array_pop($packageVersions);
487 }
488 }
489
490 // get all fromversion
491 $fromversions = [];
492 $sql = "SELECT puv.packageVersion, puf.fromversion
493 FROM wcf".WCF_N."_package_update_fromversion puf
494 LEFT JOIN wcf".WCF_N."_package_update_version puv
495 ON (puv.packageUpdateVersionID = puf.packageUpdateVersionID)
496 WHERE puf.packageUpdateVersionID IN (
497 SELECT packageUpdateVersionID
498 FROM wcf".WCF_N."_package_update_version
499 WHERE packageUpdateID IN (
500 SELECT packageUpdateID
501 FROM wcf".WCF_N."_package_update
502 WHERE package = ?
503 )
504 )";
505 $statement = WCF::getDB()->prepareStatement($sql);
506 $statement->execute([$package->package]);
507 while ($row = $statement->fetchArray()) {
508 if (!isset($fromversions[$row['packageVersion']])) $fromversions[$row['packageVersion']] = [];
509 $fromversions[$row['packageVersion']][$row['fromversion']] = $row['fromversion'];
510 }
511
512 // sort by version number
513 uksort($fromversions, [Package::class, 'compareVersion']);
514
515 // find shortest update thread
516 $updateThread = $this->findShortestUpdateThread($package->package, $fromversions, $packageVersion, $version);
517
518 // process update thread
519 foreach ($updateThread as $fromversion => $toVersion) {
520 $packageUpdateVersions = PackageUpdateDispatcher::getInstance()->getPackageUpdateVersions($package->package, $toVersion);
521
522 // resolve requirements
523 $this->resolveRequirements($packageUpdateVersions[0]['packageUpdateVersionID']);
524
525 // download package
526 $download = $this->downloadPackage($package->package, $packageUpdateVersions);
527
528 // add to stack
529 $this->packageInstallationStack[] = [
530 'packageName' => $package->getName(),
531 'fromversion' => $fromversion,
532 'toVersion' => $toVersion,
533 'package' => $package->package,
534 'packageID' => $packageID,
535 'archive' => $download,
536 'action' => 'update'
537 ];
538
539 // update virtual versions
540 $this->virtualPackageVersions[$packageID] = $toVersion;
541 }
542 }
543
544 /**
545 * Determines intermediate update steps using a backtracking algorithm in case there is no direct upgrade possible.
546 *
547 * @param string $package package identifier
548 * @param array $fromversions list of all fromversions
549 * @param string $currentVersion current package version
550 * @param string $newVersion new package version
551 * @return array list of update steps (old version => new version, old version => new version, ...)
552 * @throws SystemException
553 */
554 protected function findShortestUpdateThread($package, $fromversions, $currentVersion, $newVersion) {
555 if (!isset($fromversions[$newVersion])) {
556 throw new SystemException("An update of package ".$package." from version ".$currentVersion." to ".$newVersion." is not supported.");
557 }
558
559 // find direct update
560 foreach ($fromversions[$newVersion] as $fromversion) {
561 if (Package::checkFromversion($currentVersion, $fromversion)) {
562 return [$currentVersion => $newVersion];
563 }
564 }
565
566 // find intermediate update
567 $packageVersions = array_keys($fromversions);
568 $updateThreadList = [];
569 foreach ($fromversions[$newVersion] as $fromversion) {
570 $innerUpdateThreadList = [];
571 // find matching package versions
572 foreach ($packageVersions as $packageVersion) {
573 if (Package::checkFromversion($packageVersion, $fromversion) && Package::compareVersion($packageVersion, $currentVersion, '>') && Package::compareVersion($packageVersion, $newVersion, '<')) {
574 $innerUpdateThreadList[] = $this->findShortestUpdateThread($package, $fromversions, $currentVersion, $packageVersion) + [$packageVersion => $newVersion];
575 }
576 }
577
578 if (!empty($innerUpdateThreadList)) {
579 // sort by length
580 usort($innerUpdateThreadList, [$this, 'compareUpdateThreadLists']);
581
582 // add to thread list
583 $updateThreadList[] = array_shift($innerUpdateThreadList);
584 }
585 }
586
587 if (empty($updateThreadList)) {
588 throw new SystemException("An update of package ".$package." from version ".$currentVersion." to ".$newVersion." is not supported.");
589 }
590
591 // sort by length
592 usort($updateThreadList, [$this, 'compareUpdateThreadLists']);
593
594 // take shortest
595 return array_shift($updateThreadList);
596 }
597
598 /**
599 * Compares the length of two updates threads.
600 *
601 * @param array $updateThreadListA
602 * @param array $updateThreadListB
603 * @return integer
604 */
605 protected function compareUpdateThreadLists($updateThreadListA, $updateThreadListB) {
606 $countA = count($updateThreadListA);
607 $countB = count($updateThreadListB);
608
609 if ($countA < $countB) return -1;
610 if ($countA > $countB) return 1;
611
612 return 0;
613 }
614
615 /**
616 * Returns the filename of downloads stored in session or null if no stored downloads exist.
617 *
618 * @param string $package package identifier
619 * @param string $version package version
620 * @return string|boolean
621 */
622 protected function getCachedDownload($package, $version) {
623 $cachedDownloads = WCF::getSession()->getVar('cachedPackageUpdateDownloads');
624 if (isset($cachedDownloads[$package.'@'.$version]) && @file_exists($cachedDownloads[$package.'@'.$version])) {
625 return $cachedDownloads[$package.'@'.$version];
626 }
627
628 return false;
629 }
630
631 /**
632 * Returns stored auth data the update server with given data.
633 *
634 * @param array $data
635 * @return array
636 */
637 protected function getAuthData(array $data) {
638 $updateServer = new PackageUpdateServer(null, $data);
639 return $updateServer->getAuthData();
640 }
641 }