2 declare(strict_types
=1);
3 namespace wcf\system\package
;
4 use wcf\data\package\Package
;
5 use wcf\system\database\util\PreparedStatementConditionBuilder
;
6 use wcf\system\package\validation\PackageValidationException
;
10 use wcf\util\FileUtil
;
14 * Represents the archive of a package.
17 * @copyright 2001-2018 WoltLab GmbH
18 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
19 * @package WoltLabSuite\Core\System\Package
21 class PackageArchive
{
23 * path to package archive
29 * package object of an existing package
41 * general package information
44 protected $packageInfo = [];
50 protected $authorInfo = [];
53 * list of requirements
56 protected $requirements = [];
59 * list of optional packages
62 protected $optionals = [];
65 * list of excluded packages
68 protected $excludedPackages = [];
71 * list of compatible API versions
74 protected $compatibility = [];
77 * list of instructions
80 protected $instructions = [
86 * default name of the package.xml file
89 const INFO_FILE
= 'package.xml';
92 * Creates a new PackageArchive object.
94 * @param string $archive
95 * @param Package $package
97 public function __construct($archive, Package
$package = null) {
98 $this->archive
= $archive; // be careful: this is a string within this class,
99 // but an object in the packageStartInstallForm.class!
100 $this->package
= $package;
104 * Sets associated package object.
106 * @param Package $package
108 public function setPackage(Package
$package) {
109 $this->package
= $package;
113 * Returns the name of the package archive.
117 public function getArchive() {
118 return $this->archive
;
122 * Returns the object of the package archive.
126 public function getTar() {
131 * Opens the package archive and reads package information.
133 public function openArchive() {
134 // check whether archive exists and is a TAR archive
135 if (!file_exists($this->archive
)) {
136 throw new PackageValidationException(PackageValidationException
::FILE_NOT_FOUND
, ['archive' => $this->archive
]);
139 // open archive and read package information
140 $this->tar
= new Tar($this->archive
);
141 $this->readPackageInfo();
145 * Extracts information about this package (parses package.xml).
147 protected function readPackageInfo() {
148 // search package.xml in package archive
149 // throw error message if not found
150 if ($this->tar
->getIndexByFilename(self
::INFO_FILE
) === false) {
151 throw new PackageValidationException(PackageValidationException
::MISSING_PACKAGE_XML
, ['archive' => $this->archive
]);
154 // extract package.xml, parse XML
155 // and compile an array with XML::getElementTree()
158 $xml->loadXML(self
::INFO_FILE
, $this->tar
->extractToString(self
::INFO_FILE
));
160 catch (\Exception
$e) { // bugfix to avoid file caching problems
161 $xml->loadXML(self
::INFO_FILE
, $this->tar
->extractToString(self
::INFO_FILE
));
165 $xpath = $xml->xpath();
166 /** @var \DOMElement $package */
167 $package = $xpath->query('/ns:package')->item(0);
170 $packageName = $package->getAttribute('name');
171 if (!Package
::isValidPackageName($packageName)) {
172 // package name is not a valid package identifier
173 throw new PackageValidationException(PackageValidationException
::INVALID_PACKAGE_NAME
, ['packageName' => $packageName]);
176 $this->packageInfo
['name'] = $packageName;
178 // get package information
179 $packageInformation = $xpath->query('./ns:packageinformation', $package)->item(0);
180 $elements = $xpath->query('child::*', $packageInformation);
181 /** @var \DOMElement $element */
182 foreach ($elements as $element) {
183 switch ($element->tagName
) {
185 case 'packagedescription':
188 if (!isset($this->packageInfo
[$element->tagName
])) $this->packageInfo
[$element->tagName
] = [];
190 $languageCode = 'default';
191 if ($element->hasAttribute('language')) {
192 $languageCode = $element->getAttribute('language');
195 // fix case-sensitive names
196 $name = $element->tagName
;
197 if ($name == 'packagename') $name = 'packageName';
198 else if ($name == 'packagedescription') $name = 'packageDescription';
200 $this->packageInfo
[$name][$languageCode] = $element->nodeValue
;
203 case 'isapplication':
204 $this->packageInfo
['isApplication'] = intval($element->nodeValue
);
207 case 'applicationdirectory':
208 if (preg_match('~^[a-z0-9\-\_]+$~', $element->nodeValue
)) {
209 $this->packageInfo
['applicationDirectory'] = $element->nodeValue
;
214 $this->packageInfo
['packageURL'] = $element->nodeValue
;
218 if (!Package
::isValidVersion($element->nodeValue
)) {
219 throw new PackageValidationException(PackageValidationException
::INVALID_PACKAGE_VERSION
, ['packageVersion' => $element->nodeValue
]);
222 $this->packageInfo
['version'] = $element->nodeValue
;
226 DateUtil
::validateDate($element->nodeValue
);
228 $this->packageInfo
['date'] = @strtotime
($element->nodeValue
);
233 // get author information
234 $authorInformation = $xpath->query('./ns:authorinformation', $package)->item(0);
235 $elements = $xpath->query('child::*', $authorInformation);
236 foreach ($elements as $element) {
237 $tagName = ($element->tagName
== 'authorurl') ?
'authorURL' : $element->tagName
;
238 $this->authorInfo
[$tagName] = $element->nodeValue
;
241 // get required packages
242 $elements = $xpath->query('child::ns:requiredpackages/ns:requiredpackage', $package);
243 foreach ($elements as $element) {
244 if (!Package
::isValidPackageName($element->nodeValue
)) {
245 throw new PackageValidationException(PackageValidationException
::INVALID_PACKAGE_NAME
, ['packageName' => $element->nodeValue
]);
249 $data = ['name' => $element->nodeValue
];
250 $attributes = $xpath->query('attribute::*', $element);
251 foreach ($attributes as $attribute) {
252 $data[$attribute->name
] = $attribute->value
;
255 $this->requirements
[$element->nodeValue
] = $data;
258 // get optional packages
259 $elements = $xpath->query('child::ns:optionalpackages/ns:optionalpackage', $package);
260 foreach ($elements as $element) {
261 if (!Package
::isValidPackageName($element->nodeValue
)) {
262 throw new PackageValidationException(PackageValidationException
::INVALID_PACKAGE_NAME
, ['packageName' => $element->nodeValue
]);
266 $data = ['name' => $element->nodeValue
];
267 $attributes = $xpath->query('attribute::*', $element);
268 foreach ($attributes as $attribute) {
269 $data[$attribute->name
] = $attribute->value
;
272 $this->optionals
[] = $data;
275 // get excluded packages
276 $elements = $xpath->query('child::ns:excludedpackages/ns:excludedpackage', $package);
277 foreach ($elements as $element) {
278 if (!Package
::isValidPackageName($element->nodeValue
)) {
279 throw new PackageValidationException(PackageValidationException
::INVALID_PACKAGE_NAME
, ['packageName' => $element->nodeValue
]);
283 $data = ['name' => $element->nodeValue
];
284 $attributes = $xpath->query('attribute::*', $element);
285 foreach ($attributes as $attribute) {
286 $data[$attribute->name
] = $attribute->value
;
289 $this->excludedPackages
[] = $data;
292 // get api compatibility
293 $elements = $xpath->query('child::ns:compatibility/ns:api', $package);
294 foreach ($elements as $element) {
295 if (!$element->hasAttribute('version')) continue;
297 $version = $element->getAttribute('version');
298 if (!preg_match('~^(?:201[7-9]|20[2-9][0-9])$~', $version)) {
299 throw new PackageValidationException(PackageValidationException
::INVALID_API_VERSION
, ['version' => $version]);
302 $this->compatibility
[] = $version;
306 $elements = $xpath->query('./ns:instructions', $package);
307 foreach ($elements as $element) {
308 $instructionData = [];
309 $instructions = $xpath->query('./ns:instruction', $element);
310 /** @var \DOMElement $instruction */
311 foreach ($instructions as $instruction) {
313 $attributes = $xpath->query('attribute::*', $instruction);
314 foreach ($attributes as $attribute) {
315 $data[$attribute->name
] = $attribute->value
;
318 $instructionData[] = [
319 'attributes' => $data,
320 'pip' => $instruction->getAttribute('type'),
321 'value' => $instruction->nodeValue
325 $fromVersion = $element->getAttribute('fromversion');
326 $type = $element->getAttribute('type');
328 if ($type == 'install') {
329 $this->instructions
['install'] = $instructionData;
332 $this->instructions
['update'][$fromVersion] = $instructionData;
336 // add com.woltlab.wcf to package requirements
337 if (!isset($this->requirements
['com.woltlab.wcf']) && $this->packageInfo
['name'] != 'com.woltlab.wcf') {
338 $this->requirements
['com.woltlab.wcf'] = ['name' => 'com.woltlab.wcf'];
341 // during installations, `Package::$packageVersion` can be `null` which causes issues
342 // in `PackageArchive::filterUpdateInstructions()`; as update instructions are not needed
343 // for installations, not filtering update instructions is okay
344 if ($this->package
!== null && $this->package
->packageVersion
!== null) {
345 $this->filterUpdateInstructions();
348 // set default values
349 if (!isset($this->packageInfo
['isApplication'])) $this->packageInfo
['isApplication'] = 0;
350 if (!isset($this->packageInfo
['packageURL'])) $this->packageInfo
['packageURL'] = '';
354 * Filters update instructions.
356 protected function filterUpdateInstructions() {
357 $validFromVersion = null;
358 foreach ($this->instructions
['update'] as $fromVersion => $update) {
359 if (Package
::checkFromversion($this->package
->packageVersion
, $fromVersion)) {
360 $validFromVersion = $fromVersion;
365 if ($validFromVersion === null) {
366 $this->instructions
['update'] = [];
369 $this->instructions
['update'] = $this->instructions
['update'][$validFromVersion];
374 * Downloads the package archive.
376 * @return string path to the dowloaded file
378 public function downloadArchive() {
381 // file transfer via hypertext transfer protocol.
382 $this->archive
= FileUtil
::downloadFileFromHttp($this->archive
, $prefix);
385 $this->archive
= self
::unzipPackageArchive($this->archive
);
387 return $this->archive
;
391 * Closes and deletes the tar archive of this package.
393 public function deleteArchive() {
394 if ($this->tar
instanceof Tar
) {
398 @unlink
($this->archive
);
402 * Returns true if the package archive supports a new installation.
406 public function isValidInstall() {
407 return !empty($this->instructions
['install']);
411 * Checks if the new package is compatible with
412 * the package that is about to be updated.
414 * @param Package $package
415 * @return boolean isValidUpdate
417 public function isValidUpdate(Package
$package = null) {
418 if ($this->package
=== null && $package !== null) {
419 $this->setPackage($package);
421 // re-evaluate update data
422 $this->filterUpdateInstructions();
425 // Check name of the installed package against the name of the update. Both must be identical.
426 if ($this->packageInfo
['name'] != $this->package
->package
) {
430 // Check if the version number of the installed package is lower than the version number to which
431 // it's about to be updated.
432 if (Package
::compareVersion($this->packageInfo
['version'], $this->package
->packageVersion
) != 1) {
436 // Check if the package provides an instructions block for the update from the installed package version
437 if (empty($this->instructions
['update'])) {
445 * Checks if the current package is already installed, as it is not
446 * possible to install non-applications multiple times within the
451 public function isAlreadyInstalled() {
452 $sql = "SELECT COUNT(*)
453 FROM wcf".WCF_N
."_package
455 $statement = WCF
::getDB()->prepareStatement($sql);
456 $statement->execute([$this->packageInfo
['name']]);
458 return $statement->fetchSingleColumn() > 0;
462 * Returns true if the package is an application and has an unique abbreviation.
466 public function hasUniqueAbbreviation() {
467 if (!$this->packageInfo
['isApplication']) {
471 $sql = "SELECT COUNT(*)
472 FROM wcf".WCF_N
."_package
473 WHERE isApplication = ?
475 $statement = WCF
::getDB()->prepareStatement($sql);
476 $statement->execute([
478 '%.'.Package
::getAbbreviation($this->packageInfo
['name'])
481 return $statement->fetchSingleColumn() > 0;
485 * Returns information about the author of this package archive.
487 * @param string $name name of the requested information
490 public function getAuthorInfo($name) {
491 if (isset($this->authorInfo
[$name])) return $this->authorInfo
[$name];
496 * Returns information about this package.
498 * @param string $name name of the requested information
501 public function getPackageInfo($name) {
502 if (isset($this->packageInfo
[$name])) return $this->packageInfo
[$name];
507 * Returns a localized information about this package.
509 * @param string $name
512 public function getLocalizedPackageInfo($name) {
513 if (isset($this->packageInfo
[$name][WCF
::getLanguage()->getFixedLanguageCode()])) {
514 return $this->packageInfo
[$name][WCF
::getLanguage()->getFixedLanguageCode()];
516 else if (isset($this->packageInfo
[$name]['default'])) {
517 return $this->packageInfo
[$name]['default'];
520 if (!empty($this->packageInfo
[$name])) {
521 return reset($this->packageInfo
[$name]);
528 * Returns a list of all requirements of this package.
532 public function getRequirements() {
533 return $this->requirements
;
537 * Returns a list of all delivered optional packages of this package.
541 public function getOptionals() {
542 return $this->optionals
;
546 * Returns a list of excluded packages.
550 public function getExcludedPackages() {
551 return $this->excludedPackages
;
555 * Returns the list of compatible API versions.
559 public function getCompatibleVersions() {
560 return $this->compatibility
;
564 * Returns the package installation instructions.
568 public function getInstallInstructions() {
569 return $this->instructions
['install'];
573 * Returns the package update instructions.
577 public function getUpdateInstructions() {
578 return $this->instructions
['update'];
582 * Checks which package requirements do already exist in right version.
583 * Returns a list with all existing requirements.
587 public function getAllExistingRequirements() {
588 $existingRequirements = [];
589 $existingPackages = [];
590 if ($this->package
!== null) {
591 $sql = "SELECT package.*
592 FROM wcf".WCF_N
."_package_requirement requirement
593 LEFT JOIN wcf".WCF_N
."_package package
594 ON (package.packageID = requirement.requirement)
595 WHERE requirement.packageID = ?";
596 $statement = WCF
::getDB()->prepareStatement($sql);
597 $statement->execute([$this->package
->packageID
]);
598 while ($row = $statement->fetchArray()) {
599 $existingRequirements[$row['package']] = $row;
605 $requirements = $this->getRequirements();
606 foreach ($requirements as $requirement) {
607 if (isset($existingRequirements[$requirement['name']])) {
608 $existingPackages[$requirement['name']] = [];
609 $existingPackages[$requirement['name']][$existingRequirements[$requirement['name']]['packageID']] = $existingRequirements[$requirement['name']];
612 $packageNames[] = $requirement['name'];
616 // check whether the required packages do already exist
617 if (!empty($packageNames)) {
618 $conditions = new PreparedStatementConditionBuilder();
619 $conditions->add("package.package IN (?)", [$packageNames]);
621 $sql = "SELECT package.*
622 FROM wcf".WCF_N
."_package package
624 $statement = WCF
::getDB()->prepareStatement($sql);
625 $statement->execute($conditions->getParameters());
626 while ($row = $statement->fetchArray()) {
627 // check required package version
628 if (isset($requirements[$row['package']]['minversion']) && Package
::compareVersion($row['packageVersion'], $requirements[$row['package']]['minversion']) == -1) {
632 if (!isset($existingPackages[$row['package']])) {
633 $existingPackages[$row['package']] = [];
636 $existingPackages[$row['package']][$row['packageID']] = $row;
640 return $existingPackages;
644 * Checks which package requirements do already exist in database.
645 * Returns a list with the existing requirements.
649 public function getExistingRequirements() {
652 foreach ($this->requirements
as $requirement) {
653 $packageNames[] = $requirement['name'];
656 // check whether the required packages do already exist
657 $existingPackages = [];
658 if (!empty($packageNames)) {
659 $conditions = new PreparedStatementConditionBuilder();
660 $conditions->add("package IN (?)", [$packageNames]);
663 FROM wcf".WCF_N
."_package
665 $statement = WCF
::getDB()->prepareStatement($sql);
666 $statement->execute($conditions->getParameters());
667 while ($row = $statement->fetchArray()) {
668 if (!isset($existingPackages[$row['package']])) {
669 $existingPackages[$row['package']] = [];
672 $existingPackages[$row['package']][$row['packageVersion']] = $row;
675 // sort multiple packages by version number
676 foreach ($existingPackages as $packageName => $instances) {
677 uksort($instances, [Package
::class, 'compareVersion']);
679 // get package with highest version number (get last package)
680 $existingPackages[$packageName] = array_pop($instances);
684 return $existingPackages;
688 * Returns a list of all open requirements of this package.
692 public function getOpenRequirements() {
693 // get all existing requirements
694 $existingPackages = $this->getExistingRequirements();
696 // check for open requirements
697 $openRequirements = [];
698 foreach ($this->requirements
as $requirement) {
699 if (isset($existingPackages[$requirement['name']])) {
700 // package does already exist
701 // maybe an update is necessary
702 if (isset($requirement['minversion'])) {
703 if (Package
::compareVersion($existingPackages[$requirement['name']]['packageVersion'], $requirement['minversion']) >= 0) {
704 // package does already exist in needed version
705 // skip installation of requirement
709 $requirement['existingVersion'] = $existingPackages[$requirement['name']]['packageVersion'];
716 $requirement['packageID'] = $existingPackages[$requirement['name']]['packageID'];
717 $requirement['action'] = 'update';
720 // package does not exist
721 // new installation is necessary
722 $requirement['packageID'] = 0;
723 $requirement['action'] = 'install';
726 $openRequirements[$requirement['name']] = $requirement;
729 return $openRequirements;
733 * Extracts the requested file in the package archive to the temp folder
734 * and returns the path to the extracted file.
736 * @param string $filename
737 * @param string $tempPrefix
739 * @throws PackageValidationException
741 public function extractTar($filename, $tempPrefix = 'package_') {
742 // search the requested tar archive in our package archive.
743 // throw error message if not found.
744 if (($fileIndex = $this->tar
->getIndexByFilename($filename)) === false) {
745 throw new PackageValidationException(PackageValidationException
::FILE_NOT_FOUND
, [
746 'archive' => $this->archive
,
747 'targetArchive' => $filename
751 // requested tar archive was found
752 $fileInfo = $this->tar
->getFileInfo($fileIndex);
753 $filename = FileUtil
::getTemporaryFilename($tempPrefix, preg_replace('!^.*?(\.(?:tar\.gz|tgz|tar))$!i', '\\1', $fileInfo['filename']));
754 $this->tar
->extract($fileIndex, $filename);
760 * Unzips compressed package archives and returns the temporary file name.
762 * @param string $archive filename
765 public static function unzipPackageArchive($archive) {
766 if (!FileUtil
::isURL($archive)) {
767 $tar = new Tar($archive);
769 if ($tar->isZipped()) {
770 $tmpName = FileUtil
::getTemporaryFilename('package_');
771 if (FileUtil
::uncompressFile($archive, $tmpName)) {
781 * Returns a list of packages which exclude this package.
785 public function getConflictedExcludingPackages() {
786 $conflictedPackages = [];
787 $sql = "SELECT package.*, package_exclusion.*
788 FROM wcf".WCF_N
."_package_exclusion package_exclusion
789 LEFT JOIN wcf".WCF_N
."_package package
790 ON (package.packageID = package_exclusion.packageID)
791 WHERE excludedPackage = ?";
792 $statement = WCF
::getDB()->prepareStatement($sql);
793 $statement->execute([$this->packageInfo
['name']]);
794 while ($row = $statement->fetchArray()) {
795 if (!empty($row['excludedPackageVersion'])) {
796 if (Package
::compareVersion($this->packageInfo
['version'], $row['excludedPackageVersion'], '<')) {
801 $conflictedPackages[$row['packageID']] = new Package(null, $row);
804 return $conflictedPackages;
808 * Returns a list of packages which are excluded by this package.
812 public function getConflictedExcludedPackages() {
813 $conflictedPackages = [];
814 if (!empty($this->excludedPackages
)) {
815 $excludedPackages = [];
816 foreach ($this->excludedPackages
as $excludedPackageData) {
817 $excludedPackages[$excludedPackageData['name']] = $excludedPackageData['version'];
820 $conditions = new PreparedStatementConditionBuilder();
821 $conditions->add("package IN (?)", [array_keys($excludedPackages)]);
824 FROM wcf".WCF_N
."_package
826 $statement = WCF
::getDB()->prepareStatement($sql);
827 $statement->execute($conditions->getParameters());
828 while ($row = $statement->fetchArray()) {
829 if (!empty($excludedPackages[$row['package']])) {
830 if (Package
::compareVersion($row['packageVersion'], $excludedPackages[$row['package']], '<')) {
833 $row['excludedPackageVersion'] = $excludedPackages[$row['package']];
836 $conflictedPackages[$row['packageID']] = new Package(null, $row);
840 return $conflictedPackages;
844 * Returns a list of instructions for installation or update.
846 * @param string $type
849 public function getInstructions($type) {
850 if (isset($this->instructions
[$type])) {
851 return $this->instructions
[$type];
858 * Returns a list of php requirements for current package.
863 public function getPhpRequirements() {