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