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