Resolve language item-related PIP GUI todos
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / package / PackageUpdateDispatcher.class.php
1 <?php
2 namespace wcf\system\package;
3 use wcf\data\package\update\server\PackageUpdateServer;
4 use wcf\data\package\update\server\PackageUpdateServerEditor;
5 use wcf\data\package\update\version\PackageUpdateVersionEditor;
6 use wcf\data\package\update\PackageUpdateEditor;
7 use wcf\data\package\Package;
8 use wcf\system\cache\builder\PackageUpdateCacheBuilder;
9 use wcf\system\database\util\PreparedStatementConditionBuilder;
10 use wcf\system\exception\HTTPUnauthorizedException;
11 use wcf\system\exception\SystemException;
12 use wcf\system\io\RemoteFile;
13 use wcf\system\package\validation\PackageValidationException;
14 use wcf\system\SingletonFactory;
15 use wcf\system\WCF;
16 use wcf\util\HTTPRequest;
17 use wcf\util\JSON;
18 use wcf\util\XML;
19
20 /**
21 * Provides functions to manage package updates.
22 *
23 * @author Alexander Ebert
24 * @copyright 2001-2018 WoltLab GmbH
25 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
26 * @package WoltLabSuite\Core\System\Package
27 */
28 class PackageUpdateDispatcher extends SingletonFactory {
29 protected $hasAuthCode = false;
30 protected $purchasedVersions = [
31 'woltlab' => [],
32 'pluginstore' => []
33 ];
34
35 /**
36 * Refreshes the package database.
37 *
38 * @param integer[] $packageUpdateServerIDs
39 * @param boolean $ignoreCache
40 */
41 public function refreshPackageDatabase(array $packageUpdateServerIDs = [], $ignoreCache = false) {
42 // get update server data
43 $tmp = PackageUpdateServer::getActiveUpdateServers($packageUpdateServerIDs);
44
45 // loop servers
46 $updateServers = [];
47 $foundWoltLabServer = false;
48 $requirePurchasedVersions = false;
49 foreach ($tmp as $updateServer) {
50 if ($ignoreCache || $updateServer->lastUpdateTime < TIME_NOW - 600) {
51 if (preg_match('~^https?://(?:update|store)\.woltlab\.com\/~', $updateServer->serverURL)) {
52 $requirePurchasedVersions = true;
53
54 // move a woltlab.com update server to the front of the queue to probe for SSL support
55 if (!$foundWoltLabServer) {
56 array_unshift($updateServers, $updateServer);
57 $foundWoltLabServer = true;
58
59 continue;
60 }
61 }
62
63 $updateServers[] = $updateServer;
64 }
65 }
66
67 if ($requirePurchasedVersions && PACKAGE_SERVER_AUTH_CODE) {
68 $this->getPurchasedVersions();
69 }
70
71 // loop servers
72 $refreshedPackageLists = false;
73 foreach ($updateServers as $updateServer) {
74 $errorMessage = '';
75
76 try {
77 $this->getPackageUpdateXML($updateServer);
78 $refreshedPackageLists = true;
79 }
80 catch (SystemException $e) {
81 $errorMessage = $e->getMessage();
82 }
83 catch (PackageUpdateUnauthorizedException $e) {
84 $reply = $e->getRequest()->getReply();
85 list($errorMessage) = reset($reply['httpHeaders']);
86 }
87
88 if ($errorMessage) {
89 // save error status
90 $updateServerEditor = new PackageUpdateServerEditor($updateServer);
91 $updateServerEditor->update([
92 'status' => 'offline',
93 'errorMessage' => $errorMessage
94 ]);
95 }
96 }
97
98 if ($refreshedPackageLists) {
99 PackageUpdateCacheBuilder::getInstance()->reset();
100 }
101 }
102
103 protected function getPurchasedVersions() {
104 if (!RemoteFile::supportsSSL()) {
105 return;
106 }
107
108 $request = new HTTPRequest(
109 'https://api.woltlab.com/1.0/customer/license/list.json',
110 ['timeout' => 5],
111 ['authCode' => PACKAGE_SERVER_AUTH_CODE]
112 );
113
114 try {
115 $request->execute();
116 $reply = JSON::decode($request->getReply()['body']);
117 if ($reply['status'] == 200) {
118 $this->hasAuthCode = true;
119 $this->purchasedVersions = [
120 'woltlab' => (isset($reply['woltlab']) ? $reply['woltlab'] : []),
121 'pluginstore' => (isset($reply['pluginstore']) ? $reply['pluginstore'] : [])
122 ];
123 }
124 }
125 catch (SystemException $e) {
126 // ignore
127 }
128 }
129
130 /**
131 * Fetches the package_update.xml from an update server.
132 *
133 * @param PackageUpdateServer $updateServer
134 * @param boolean $forceHTTP
135 * @throws PackageUpdateUnauthorizedException
136 * @throws SystemException
137 */
138 protected function getPackageUpdateXML(PackageUpdateServer $updateServer, $forceHTTP = false) {
139 $settings = [];
140 $authData = $updateServer->getAuthData();
141 if ($authData) $settings['auth'] = $authData;
142
143 $secureConnection = $updateServer->attemptSecureConnection();
144 if ($secureConnection && !$forceHTTP) $settings['timeout'] = 5;
145
146 $request = new HTTPRequest($updateServer->getListURL($forceHTTP), $settings);
147
148 $apiVersion = $updateServer->apiVersion;
149 if (in_array($apiVersion, ['2.1', '3.1'])) {
150 // skip etag check for WoltLab servers when an auth code is provided
151 if (!preg_match('~^https?://(?:update|store)\.woltlab\.com\/~', $updateServer->serverURL) || !PACKAGE_SERVER_AUTH_CODE) {
152 $metaData = $updateServer->getMetaData();
153 if (isset($metaData['list']['etag'])) $request->addHeader('if-none-match', $metaData['list']['etag']);
154 if (isset($metaData['list']['lastModified'])) $request->addHeader('if-modified-since', $metaData['list']['lastModified']);
155 }
156 }
157
158 try {
159 $request->execute();
160 $reply = $request->getReply();
161 }
162 catch (HTTPUnauthorizedException $e) {
163 throw new PackageUpdateUnauthorizedException($request, $updateServer);
164 }
165 catch (SystemException $e) {
166 $reply = $request->getReply();
167
168 $statusCode = is_array($reply['statusCode']) ? reset($reply['statusCode']) : $reply['statusCode'];
169 // status code 0 is a connection timeout
170 if (!$statusCode && $secureConnection) {
171 if (preg_match('~https?://(?:update|store)\.woltlab\.com\/~', $updateServer->serverURL)) {
172 // woltlab.com servers are most likely to be available, thus we assume that SSL connections are dropped
173 RemoteFile::disableSSL();
174 }
175
176 // retry via http
177 $this->getPackageUpdateXML($updateServer, true);
178 return;
179 }
180
181 throw new SystemException(WCF::getLanguage()->get('wcf.acp.package.update.error.listNotFound') . ' ('.$statusCode.')');
182 }
183
184 $data = [
185 'lastUpdateTime' => TIME_NOW,
186 'status' => 'online',
187 'errorMessage' => ''
188 ];
189
190 // check if server indicates support for a newer API
191 if ($updateServer->apiVersion !== '3.1' && !empty($reply['httpHeaders']['wcf-update-server-api'])) {
192 $apiVersions = explode(' ', reset($reply['httpHeaders']['wcf-update-server-api']));
193 if (in_array('3.1', $apiVersions)) {
194 $apiVersion = $data['apiVersion'] = '3.1';
195 }
196 else if (in_array('2.1', $apiVersions)) {
197 $apiVersion = $data['apiVersion'] = '2.1';
198 }
199 }
200
201 // parse given package update xml
202 $allNewPackages = false;
203 if ($apiVersion === '2.0' || $reply['statusCode'] != 304) {
204 $allNewPackages = $this->parsePackageUpdateXML($updateServer, $reply['body'], $apiVersion);
205 }
206
207 $metaData = [];
208 if (in_array($apiVersion, ['2.1', '3.1'])) {
209 if (empty($reply['httpHeaders']['etag']) && empty($reply['httpHeaders']['last-modified'])) {
210 throw new SystemException("Missing required HTTP headers 'etag' and 'last-modified'.");
211 }
212 else if (empty($reply['httpHeaders']['wcf-update-server-ssl'])) {
213 throw new SystemException("Missing required HTTP header 'wcf-update-server-ssl'.");
214 }
215
216 $metaData['list'] = [];
217 if (!empty($reply['httpHeaders']['etag'])) $metaData['list']['etag'] = reset($reply['httpHeaders']['etag']);
218 if (!empty($reply['httpHeaders']['last-modified'])) $metaData['list']['lastModified'] = reset($reply['httpHeaders']['last-modified']);
219
220 $metaData['ssl'] = (reset($reply['httpHeaders']['wcf-update-server-ssl']) == 'true') ? true : false;
221 }
222 $data['metaData'] = serialize($metaData);
223
224 unset($request, $reply);
225
226 if ($allNewPackages !== false) {
227 // purge package list
228 $sql = "DELETE FROM wcf".WCF_N."_package_update
229 WHERE packageUpdateServerID = ?";
230 $statement = WCF::getDB()->prepareStatement($sql);
231 $statement->execute([$updateServer->packageUpdateServerID]);
232
233 // save packages
234 if (!empty($allNewPackages)) {
235 $this->savePackageUpdates($allNewPackages, $updateServer->packageUpdateServerID);
236 }
237 unset($allNewPackages);
238 }
239
240 // update server status
241 $updateServerEditor = new PackageUpdateServerEditor($updateServer);
242 $updateServerEditor->update($data);
243 }
244
245 /**
246 * Parses a stream containing info from a packages_update.xml.
247 *
248 * @param PackageUpdateServer $updateServer
249 * @param string $content
250 * @param string $apiVersion
251 * @return array
252 * @throws SystemException
253 */
254 protected function parsePackageUpdateXML(PackageUpdateServer $updateServer, $content, $apiVersion) {
255 $isTrustedServer = $updateServer->isTrustedServer();
256
257 // load xml document
258 $xml = new XML();
259 $xml->loadXML('packageUpdateServer.xml', $content);
260 $xpath = $xml->xpath();
261
262 $allNewPackages = [];
263 $packages = $xpath->query('/ns:section/ns:package');
264 /** @var \DOMElement $package */
265 foreach ($packages as $package) {
266 if (!Package::isValidPackageName($package->getAttribute('name'))) {
267 throw new SystemException("'".$package->getAttribute('name')."' is not a valid package name.");
268 }
269
270 $name = $package->getAttribute('name');
271 if (strpos($name, 'com.woltlab.') === 0 && !$isTrustedServer) {
272 if (ENABLE_DEBUG_MODE && ENABLE_DEVELOPER_TOOLS) {
273 throw new SystemException("The server '".$updateServer->serverURL."' attempted to provide an official WoltLab package, but is not authorized.");
274 }
275
276 // silently ignore this package to avoid unexpected errors for regular users
277 continue;
278 }
279
280 $allNewPackages[$name] = $this->parsePackageUpdateXMLBlock($updateServer, $xpath, $package, $apiVersion);
281 }
282
283 return $allNewPackages;
284 }
285
286 /**
287 * Parses the xml structure from a packages_update.xml.
288 *
289 * @param PackageUpdateServer $updateServer
290 * @param \DOMXPath $xpath
291 * @param \DOMElement $package
292 * @param string $apiVersion
293 * @return array
294 * @throws PackageValidationException
295 */
296 protected function parsePackageUpdateXMLBlock(PackageUpdateServer $updateServer, \DOMXPath $xpath, \DOMElement $package, $apiVersion) {
297 // define default values
298 $packageInfo = [
299 'author' => '',
300 'authorURL' => '',
301 'isApplication' => 0,
302 'packageDescription' => '',
303 'versions' => [],
304 'pluginStoreFileID' => 0
305 ];
306
307 // parse package information
308 $elements = $xpath->query('./ns:packageinformation/*', $package);
309 foreach ($elements as $element) {
310 switch ($element->tagName) {
311 case 'packagename':
312 $packageInfo['packageName'] = $element->nodeValue;
313 break;
314
315 case 'packagedescription':
316 $packageInfo['packageDescription'] = $element->nodeValue;
317 break;
318
319 case 'isapplication':
320 $packageInfo['isApplication'] = intval($element->nodeValue);
321 break;
322
323 case 'pluginStoreFileID':
324 if ($updateServer->isWoltLabStoreServer()) {
325 $packageInfo['pluginStoreFileID'] = intval($element->nodeValue);
326 }
327 break;
328 }
329 }
330
331 // parse author information
332 $elements = $xpath->query('./ns:authorinformation/*', $package);
333 foreach ($elements as $element) {
334 switch ($element->tagName) {
335 case 'author':
336 $packageInfo['author'] = $element->nodeValue;
337 break;
338
339 case 'authorurl':
340 $packageInfo['authorURL'] = $element->nodeValue;
341 break;
342 }
343 }
344
345 $key = '';
346 if ($this->hasAuthCode) {
347 if ($updateServer->isWoltLabUpdateServer()) $key = 'woltlab';
348 else if ($updateServer->isWoltLabStoreServer()) $key = 'pluginstore';
349 }
350
351 // parse versions
352 $elements = $xpath->query('./ns:versions/ns:version', $package);
353 /** @var \DOMElement $element */
354 foreach ($elements as $element) {
355 $versionNo = $element->getAttribute('name');
356
357 $isAccessible = ($element->getAttribute('accessible') == 'true') ? 1 : 0;
358 if ($key && $element->getAttribute('requireAuth') == 'true') {
359 $packageName = $package->getAttribute('name');
360 if (isset($this->purchasedVersions[$key][$packageName])) {
361 if ($this->purchasedVersions[$key][$packageName] == '*') {
362 $isAccessible = 1;
363 }
364 else {
365 $isAccessible = (Package::compareVersion($versionNo, $this->purchasedVersions[$key][$packageName] . '99', '<=') ? 1 : 0);
366 }
367 }
368 else {
369 $isAccessible = 0;
370 }
371 }
372
373 $packageInfo['versions'][$versionNo] = ['isAccessible' => $isAccessible];
374
375 $children = $xpath->query('child::*', $element);
376 /** @var \DOMElement $child */
377 foreach ($children as $child) {
378 switch ($child->tagName) {
379 case 'fromversions':
380 $fromversions = $xpath->query('child::*', $child);
381 foreach ($fromversions as $fromversion) {
382 $packageInfo['versions'][$versionNo]['fromversions'][] = $fromversion->nodeValue;
383 }
384 break;
385
386 case 'timestamp':
387 $packageInfo['versions'][$versionNo]['packageDate'] = $child->nodeValue;
388 break;
389
390 case 'file':
391 $packageInfo['versions'][$versionNo]['file'] = $child->nodeValue;
392 break;
393
394 case 'requiredpackages':
395 $requiredPackages = $xpath->query('child::*', $child);
396
397 /** @var \DOMElement $requiredPackage */
398 foreach ($requiredPackages as $requiredPackage) {
399 $minVersion = $requiredPackage->getAttribute('minversion');
400 $required = $requiredPackage->nodeValue;
401
402 $packageInfo['versions'][$versionNo]['requiredPackages'][$required] = [];
403 if (!empty($minVersion)) {
404 $packageInfo['versions'][$versionNo]['requiredPackages'][$required]['minversion'] = $minVersion;
405 }
406 }
407 break;
408
409 case 'optionalpackages':
410 $packageInfo['versions'][$versionNo]['optionalPackages'] = [];
411
412 $optionalPackages = $xpath->query('child::*', $child);
413 foreach ($optionalPackages as $optionalPackage) {
414 $packageInfo['versions'][$versionNo]['optionalPackages'][] = $optionalPackage->nodeValue;
415 }
416 break;
417
418 case 'excludedpackages':
419 $excludedpackages = $xpath->query('child::*', $child);
420 /** @var \DOMElement $excludedPackage */
421 foreach ($excludedpackages as $excludedPackage) {
422 $exclusion = $excludedPackage->nodeValue;
423 $version = $excludedPackage->getAttribute('version');
424
425 $packageInfo['versions'][$versionNo]['excludedPackages'][$exclusion] = [];
426 if (!empty($version)) {
427 $packageInfo['versions'][$versionNo]['excludedPackages'][$exclusion]['version'] = $version;
428 }
429 }
430 break;
431
432 case 'license':
433 $packageInfo['versions'][$versionNo]['license'] = [
434 'license' => $child->nodeValue,
435 'licenseURL' => $child->hasAttribute('url') ? $child->getAttribute('url') : ''
436 ];
437 break;
438
439 case 'compatibility':
440 if ($apiVersion !== '3.1') continue 2;
441
442 $packageInfo['versions'][$versionNo]['compatibility'] = [];
443
444 /** @var \DOMElement $compatibleVersion */
445 foreach ($xpath->query('child::*', $child) as $compatibleVersion) {
446 if ($compatibleVersion->nodeName === 'api' && $compatibleVersion->hasAttribute('version')) {
447 $versionNumber = $compatibleVersion->getAttribute('version');
448 if (!preg_match('~^(?:201[7-9]|20[2-9][0-9])$~', $versionNumber)) {
449 if (ENABLE_DEBUG_MODE && ENABLE_DEVELOPER_TOOLS) {
450 throw new PackageValidationException(PackageValidationException::INVALID_API_VERSION, ['version' => $versionNumber]);
451 }
452 else {
453 continue;
454 }
455 }
456
457 $packageInfo['versions'][$versionNo]['compatibility'][] = $versionNumber;
458 }
459 }
460 break;
461 }
462 }
463 }
464
465 return $packageInfo;
466 }
467
468 /**
469 * Updates information parsed from a packages_update.xml into the database.
470 *
471 * @param array $allNewPackages
472 * @param integer $packageUpdateServerID
473 */
474 protected function savePackageUpdates(array &$allNewPackages, $packageUpdateServerID) {
475 // insert updates
476 $excludedPackagesParameters = $fromversionParameters = $insertParameters = $optionalInserts = $requirementInserts = $compatibilityInserts = [];
477 WCF::getDB()->beginTransaction();
478 foreach ($allNewPackages as $identifier => $packageData) {
479 // create new database entry
480 $packageUpdate = PackageUpdateEditor::create([
481 'packageUpdateServerID' => $packageUpdateServerID,
482 'package' => $identifier,
483 'packageName' => $packageData['packageName'],
484 'packageDescription' => $packageData['packageDescription'],
485 'author' => $packageData['author'],
486 'authorURL' => $packageData['authorURL'],
487 'isApplication' => $packageData['isApplication'],
488 'pluginStoreFileID' => $packageData['pluginStoreFileID']
489 ]);
490
491 $packageUpdateID = $packageUpdate->packageUpdateID;
492
493 // register version(s) of this update package.
494 if (isset($packageData['versions'])) {
495 foreach ($packageData['versions'] as $packageVersion => $versionData) {
496 if (isset($versionData['file'])) $packageFile = $versionData['file'];
497 else $packageFile = '';
498
499 // create new database entry
500 $version = PackageUpdateVersionEditor::create([
501 'filename' => $packageFile,
502 'license' => isset($versionData['license']['license']) ? $versionData['license']['license'] : '',
503 'licenseURL' => isset($versionData['license']['license']) ? $versionData['license']['licenseURL'] : '',
504 'isAccessible' => $versionData['isAccessible'] ? 1 : 0,
505 'packageDate' => $versionData['packageDate'],
506 'packageUpdateID' => $packageUpdateID,
507 'packageVersion' => $packageVersion
508 ]);
509
510 $packageUpdateVersionID = $version->packageUpdateVersionID;
511
512 // register requirement(s) of this update package version.
513 if (isset($versionData['requiredPackages'])) {
514 foreach ($versionData['requiredPackages'] as $requiredIdentifier => $required) {
515 $requirementInserts[] = [
516 'packageUpdateVersionID' => $packageUpdateVersionID,
517 'package' => $requiredIdentifier,
518 'minversion' => isset($required['minversion']) ? $required['minversion'] : ''
519 ];
520 }
521 }
522
523 // register optional packages of this update package version
524 if (isset($versionData['optionalPackages'])) {
525 foreach ($versionData['optionalPackages'] as $optionalPackage) {
526 $optionalInserts[] = [
527 'packageUpdateVersionID' => $packageUpdateVersionID,
528 'package' => $optionalPackage
529 ];
530 }
531 }
532
533 // register excluded packages of this update package version.
534 if (isset($versionData['excludedPackages'])) {
535 foreach ($versionData['excludedPackages'] as $excludedIdentifier => $exclusion) {
536 $excludedPackagesParameters[] = [
537 'packageUpdateVersionID' => $packageUpdateVersionID,
538 'excludedPackage' => $excludedIdentifier,
539 'excludedPackageVersion' => isset($exclusion['version']) ? $exclusion['version'] : ''
540 ];
541 }
542 }
543
544 // register fromversions of this update package version.
545 if (isset($versionData['fromversions'])) {
546 foreach ($versionData['fromversions'] as $fromversion) {
547 $fromversionInserts[] = [
548 'packageUpdateVersionID' => $packageUpdateVersionID,
549 'fromversion' => $fromversion
550 ];
551 }
552 }
553
554 // register compatibility versions of this update package version.
555 if (isset($versionData['compatibility'])) {
556 foreach ($versionData['compatibility'] as $version) {
557 $compatibilityInserts[] = [
558 'packageUpdateVersionID' => $packageUpdateVersionID,
559 'version' => $version
560 ];
561 }
562 }
563 }
564 }
565 }
566 WCF::getDB()->commitTransaction();
567
568 // save requirements, excluded packages and fromversions
569 // insert requirements
570 if (!empty($requirementInserts)) {
571 $sql = "INSERT INTO wcf".WCF_N."_package_update_requirement
572 (packageUpdateVersionID, package, minversion)
573 VALUES (?, ?, ?)";
574 $statement = WCF::getDB()->prepareStatement($sql);
575 WCF::getDB()->beginTransaction();
576 foreach ($requirementInserts as $requirement) {
577 $statement->execute([
578 $requirement['packageUpdateVersionID'],
579 $requirement['package'],
580 $requirement['minversion']
581 ]);
582 }
583 WCF::getDB()->commitTransaction();
584 }
585
586 // insert optionals
587 if (!empty($optionalInserts)) {
588 $sql = "INSERT INTO wcf".WCF_N."_package_update_optional
589 (packageUpdateVersionID, package)
590 VALUES (?, ?)";
591 $statement = WCF::getDB()->prepareStatement($sql);
592 WCF::getDB()->beginTransaction();
593 foreach ($optionalInserts as $requirement) {
594 $statement->execute([
595 $requirement['packageUpdateVersionID'],
596 $requirement['package']
597 ]);
598 }
599 WCF::getDB()->commitTransaction();
600 }
601
602 // insert excludes
603 if (!empty($excludedPackagesParameters)) {
604 $sql = "INSERT INTO wcf".WCF_N."_package_update_exclusion
605 (packageUpdateVersionID, excludedPackage, excludedPackageVersion)
606 VALUES (?, ?, ?)";
607 $statement = WCF::getDB()->prepareStatement($sql);
608 WCF::getDB()->beginTransaction();
609 foreach ($excludedPackagesParameters as $excludedPackage) {
610 $statement->execute([
611 $excludedPackage['packageUpdateVersionID'],
612 $excludedPackage['excludedPackage'],
613 $excludedPackage['excludedPackageVersion']
614 ]);
615 }
616 WCF::getDB()->commitTransaction();
617 }
618
619 // insert fromversions
620 if (!empty($fromversionInserts)) {
621 $sql = "INSERT INTO wcf".WCF_N."_package_update_fromversion
622 (packageUpdateVersionID, fromversion)
623 VALUES (?, ?)";
624 $statement = WCF::getDB()->prepareStatement($sql);
625 WCF::getDB()->beginTransaction();
626 foreach ($fromversionInserts as $fromversion) {
627 $statement->execute([
628 $fromversion['packageUpdateVersionID'],
629 $fromversion['fromversion']
630 ]);
631 }
632 WCF::getDB()->commitTransaction();
633 }
634
635 // insert compatibility versions
636 if (!empty($compatibilityInserts)) {
637 $sql = "INSERT INTO wcf".WCF_N."_package_update_compatibility
638 (packageUpdateVersionID, version)
639 VALUES (?, ?)";
640 $statement = WCF::getDB()->prepareStatement($sql);
641 WCF::getDB()->beginTransaction();
642 foreach ($compatibilityInserts as $versionData) {
643 $statement->execute([
644 $versionData['packageUpdateVersionID'],
645 $versionData['version']
646 ]);
647 }
648 WCF::getDB()->commitTransaction();
649 }
650 }
651
652 /**
653 * Returns a list of available updates for installed packages.
654 *
655 * @param boolean $removeRequirements
656 * @param boolean $removeOlderMinorReleases
657 * @return array
658 * @throws SystemException
659 */
660 public function getAvailableUpdates($removeRequirements = true, $removeOlderMinorReleases = false) {
661 $updates = [];
662
663 // get update server data
664 $updateServers = PackageUpdateServer::getActiveUpdateServers();
665 $packageUpdateServerIDs = array_keys($updateServers);
666 if (empty($packageUpdateServerIDs)) return $updates;
667
668 // get existing packages and their versions
669 $existingPackages = [];
670 $sql = "SELECT packageID, package, packageDescription, packageName,
671 packageVersion, packageDate, author, authorURL, isApplication
672 FROM wcf".WCF_N."_package";
673 $statement = WCF::getDB()->prepareStatement($sql);
674 $statement->execute();
675 while ($row = $statement->fetchArray()) {
676 $existingPackages[$row['package']][] = $row;
677 }
678 if (empty($existingPackages)) return $updates;
679
680 // get all update versions
681 $conditions = new PreparedStatementConditionBuilder();
682 $conditions->add("pu.packageUpdateServerID IN (?)", [$packageUpdateServerIDs]);
683 $conditions->add("package IN (SELECT DISTINCT package FROM wcf".WCF_N."_package)");
684
685 $sql = "SELECT pu.packageUpdateID, pu.packageUpdateServerID, pu.package,
686 puv.packageUpdateVersionID, puv.packageDate, puv.filename, puv.packageVersion
687 FROM wcf".WCF_N."_package_update pu
688 LEFT JOIN wcf".WCF_N."_package_update_version puv
689 ON (puv.packageUpdateID = pu.packageUpdateID AND puv.isAccessible = 1)
690 ".$conditions;
691 $statement = WCF::getDB()->prepareStatement($sql);
692 $statement->execute($conditions->getParameters());
693 while ($row = $statement->fetchArray()) {
694 if (!isset($existingPackages[$row['package']])) {
695 if (ENABLE_DEBUG_MODE && ENABLE_DEVELOPER_TOOLS) {
696 throw new SystemException("Invalid package update data, identifier '" . $row['package'] . "' does not match any installed package (case-mismatch).");
697 }
698
699 // case-mismatch, skip the update
700 continue;
701 }
702
703 // test version
704 foreach ($existingPackages[$row['package']] as $existingVersion) {
705 if (Package::compareVersion($existingVersion['packageVersion'], $row['packageVersion'], '<')) {
706 // package data
707 if (!isset($updates[$existingVersion['packageID']])) {
708 $existingVersion['versions'] = [];
709 $updates[$existingVersion['packageID']] = $existingVersion;
710 }
711
712 // version data
713 if (!isset($updates[$existingVersion['packageID']]['versions'][$row['packageVersion']])) {
714 $updates[$existingVersion['packageID']]['versions'][$row['packageVersion']] = [
715 'packageDate' => $row['packageDate'],
716 'packageVersion' => $row['packageVersion'],
717 'servers' => []
718 ];
719 }
720
721 // server data
722 $updates[$existingVersion['packageID']]['versions'][$row['packageVersion']]['servers'][] = [
723 'packageUpdateID' => $row['packageUpdateID'],
724 'packageUpdateServerID' => $row['packageUpdateServerID'],
725 'packageUpdateVersionID' => $row['packageUpdateVersionID'],
726 'filename' => $row['filename']
727 ];
728 }
729 }
730 }
731
732 // sort package versions
733 // and remove old versions
734 foreach ($updates as $packageID => $data) {
735 uksort($updates[$packageID]['versions'], ['wcf\data\package\Package', 'compareVersion']);
736 $updates[$packageID]['version'] = end($updates[$packageID]['versions']);
737 }
738
739 // remove requirements of application packages
740 if ($removeRequirements) {
741 foreach ($existingPackages as $identifier => $instances) {
742 foreach ($instances as $instance) {
743 if ($instance['isApplication'] && isset($updates[$instance['packageID']])) {
744 $updates = $this->removeUpdateRequirements($updates, $updates[$instance['packageID']]['version']['servers'][0]['packageUpdateVersionID']);
745 }
746 }
747 }
748 }
749
750 // remove older minor releases from list, e.g. only display 1.0.2, even if 1.0.1 is available
751 if ($removeOlderMinorReleases) {
752 foreach ($updates as &$updateData) {
753 $highestVersions = [];
754 foreach ($updateData['versions'] as $versionNumber => $dummy) {
755 if (preg_match('~^(\d+\.\d+)\.~', $versionNumber, $matches)) {
756 $major = $matches[1];
757 if (isset($highestVersions[$major])) {
758 if (Package::compareVersion($highestVersions[$major], $versionNumber, '<')) {
759 // version is newer, discard current version
760 unset($updateData['versions'][$highestVersions[$major]]);
761 $highestVersions[$major] = $versionNumber;
762 }
763 else {
764 // version is lower, discard
765 unset($updateData['versions'][$versionNumber]);
766 }
767 }
768 else {
769 $highestVersions[$major] = $versionNumber;
770 }
771 }
772 }
773 }
774 unset($updateData);
775 }
776
777 return $updates;
778 }
779
780 /**
781 * Removes unnecessary updates of requirements from the list of available updates.
782 *
783 * @param array $updates
784 * @param integer $packageUpdateVersionID
785 * @return array $updates
786 */
787 protected function removeUpdateRequirements(array $updates, $packageUpdateVersionID) {
788 $sql = "SELECT pur.package, pur.minversion, p.packageID
789 FROM wcf".WCF_N."_package_update_requirement pur
790 LEFT JOIN wcf".WCF_N."_package p
791 ON (p.package = pur.package)
792 WHERE pur.packageUpdateVersionID = ?";
793 $statement = WCF::getDB()->prepareStatement($sql);
794 $statement->execute([$packageUpdateVersionID]);
795 while ($row = $statement->fetchArray()) {
796 if (isset($updates[$row['packageID']])) {
797 $updates = $this->removeUpdateRequirements($updates, $updates[$row['packageID']]['version']['servers'][0]['packageUpdateVersionID']);
798 if (Package::compareVersion($row['minversion'], $updates[$row['packageID']]['version']['packageVersion'], '>=')) {
799 unset($updates[$row['packageID']]);
800 }
801 }
802 }
803
804 return $updates;
805 }
806
807 /**
808 * Creates a new package installation scheduler.
809 *
810 * @param array $selectedPackages
811 * @return PackageInstallationScheduler
812 */
813 public function prepareInstallation(array $selectedPackages) {
814 return new PackageInstallationScheduler($selectedPackages);
815 }
816
817 /**
818 * Returns package update versions of the specified package.
819 *
820 * @param string $package package identifier
821 * @param string $version package version
822 * @return array package update versions
823 * @throws SystemException
824 */
825 public function getPackageUpdateVersions($package, $version = '') {
826 // get newest package version
827 if (empty($version)) {
828 $version = $this->getNewestPackageVersion($package);
829 }
830
831 // get versions
832 $sql = "SELECT puv.*, pu.*, pus.loginUsername, pus.loginPassword
833 FROM wcf".WCF_N."_package_update_version puv
834 LEFT JOIN wcf".WCF_N."_package_update pu
835 ON (pu.packageUpdateID = puv.packageUpdateID)
836 LEFT JOIN wcf".WCF_N."_package_update_server pus
837 ON (pus.packageUpdateServerID = pu.packageUpdateServerID)
838 WHERE pu.package = ?
839 AND puv.packageVersion = ?
840 AND puv.isAccessible = ?
841 AND pus.isDisabled = ?";
842 $statement = WCF::getDB()->prepareStatement($sql);
843 $statement->execute([
844 $package,
845 $version,
846 1,
847 0
848 ]);
849 $versions = $statement->fetchAll(\PDO::FETCH_ASSOC);
850
851 if (empty($versions)) {
852 throw new SystemException("Cannot find package '".$package."' in version '".$version."'");
853 }
854
855 return $versions;
856 }
857
858 /**
859 * Returns the newest available version of a package.
860 *
861 * @param string $package package identifier
862 * @return string newest package version
863 */
864 public function getNewestPackageVersion($package) {
865 // get all versions
866 $versions = [];
867 $sql = "SELECT packageVersion
868 FROM wcf".WCF_N."_package_update_version
869 WHERE packageUpdateID IN (
870 SELECT packageUpdateID
871 FROM wcf".WCF_N."_package_update
872 WHERE package = ?
873 )";
874 $statement = WCF::getDB()->prepareStatement($sql);
875 $statement->execute([$package]);
876 while ($row = $statement->fetchArray()) {
877 $versions[$row['packageVersion']] = $row['packageVersion'];
878 }
879
880 // sort by version number
881 usort($versions, [Package::class, 'compareVersion']);
882
883 // take newest (last)
884 return array_pop($versions);
885 }
886
887 /**
888 * Stores the filename of a download in session.
889 *
890 * @param string $package package identifier
891 * @param string $version package version
892 * @param string $filename
893 */
894 public function cacheDownload($package, $version, $filename) {
895 $cachedDownloads = WCF::getSession()->getVar('cachedPackageUpdateDownloads');
896 if (!is_array($cachedDownloads)) {
897 $cachedDownloads = [];
898 }
899
900 // store in session
901 $cachedDownloads[$package.'@'.$version] = $filename;
902 WCF::getSession()->register('cachedPackageUpdateDownloads', $cachedDownloads);
903 }
904 }