2 namespace wcf\system\package
;
3 use wcf\data\package\Package
;
4 use wcf\system\database\util\PreparedStatementConditionBuilder
;
5 use wcf\system\package\validation\PackageValidationException
;
13 * Represents the archive of a package.
16 * @copyright 2001-2018 WoltLab GmbH
17 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
18 * @package WoltLabSuite\Core\System\Package
20 class PackageArchive
{
22 * path to package archive
28 * package object of an existing package
40 * general package information
43 protected $packageInfo = [];
49 protected $authorInfo = [];
52 * list of requirements
55 protected $requirements = [];
58 * list of optional packages
61 protected $optionals = [];
64 * list of excluded packages
67 protected $excludedPackages = [];
70 * list of compatible API versions
73 protected $compatibility = [];
76 * list of instructions
79 protected $instructions = [
85 * default name of the package.xml file
88 const INFO_FILE
= 'package.xml';
91 * Creates a new PackageArchive object.
93 * @param string $archive
94 * @param Package $package
96 public function __construct($archive, Package
$package = null) {
97 $this->archive
= $archive; // be careful: this is a string within this class,
98 // but an object in the packageStartInstallForm.class!
99 $this->package
= $package;
103 * Sets associated package object.
105 * @param Package $package
107 public function setPackage(Package
$package) {
108 $this->package
= $package;
112 * Returns the name of the package archive.
116 public function getArchive() {
117 return $this->archive
;
121 * Returns the object of the package archive.
125 public function getTar() {
130 * Opens the package archive and reads package information.
132 public function openArchive() {
133 // check whether archive exists and is a TAR archive
134 if (!file_exists($this->archive
)) {
135 throw new PackageValidationException(PackageValidationException
::FILE_NOT_FOUND
, ['archive' => $this->archive
]);
138 // open archive and read package information
139 $this->tar
= new Tar($this->archive
);
140 $this->readPackageInfo();
144 * Extracts information about this package (parses package.xml).
146 protected function readPackageInfo() {
147 // search package.xml in package archive
148 // throw error message if not found
149 if ($this->tar
->getIndexByFilename(self
::INFO_FILE
) === false) {
150 throw new PackageValidationException(PackageValidationException
::MISSING_PACKAGE_XML
, ['archive' => $this->archive
]);
153 // extract package.xml, parse XML
154 // and compile an array with XML::getElementTree()
157 $xml->loadXML(self
::INFO_FILE
, $this->tar
->extractToString(self
::INFO_FILE
));
159 catch (\Exception
$e) { // bugfix to avoid file caching problems
160 $xml->loadXML(self
::INFO_FILE
, $this->tar
->extractToString(self
::INFO_FILE
));
164 $xpath = $xml->xpath();
165 /** @var \DOMElement $package */
166 $package = $xpath->query('/ns:package')->item(0);
169 $packageName = $package->getAttribute('name');
170 if (!Package
::isValidPackageName($packageName)) {
171 // package name is not a valid package identifier
172 throw new PackageValidationException(PackageValidationException
::INVALID_PACKAGE_NAME
, ['packageName' => $packageName]);
175 $this->packageInfo
['name'] = $packageName;
177 // get package information
178 $packageInformation = $xpath->query('./ns:packageinformation', $package)->item(0);
179 $elements = $xpath->query('child::*', $packageInformation);
180 /** @var \DOMElement $element */
181 foreach ($elements as $element) {
182 switch ($element->tagName
) {
184 case 'packagedescription':
187 if (!isset($this->packageInfo
[$element->tagName
])) $this->packageInfo
[$element->tagName
] = [];
189 $languageCode = 'default';
190 if ($element->hasAttribute('language')) {
191 $languageCode = $element->getAttribute('language');
194 // fix case-sensitive names
195 $name = $element->tagName
;
196 if ($name == 'packagename') $name = 'packageName';
197 else if ($name == 'packagedescription') $name = 'packageDescription';
199 $this->packageInfo
[$name][$languageCode] = $element->nodeValue
;
202 case 'isapplication':
203 $this->packageInfo
['isApplication'] = intval($element->nodeValue
);
206 case 'applicationdirectory':
207 if (preg_match('~^[a-z0-9\-\_]+$~', $element->nodeValue
)) {
208 $this->packageInfo
['applicationDirectory'] = $element->nodeValue
;
213 $this->packageInfo
['packageURL'] = $element->nodeValue
;
217 if (!Package
::isValidVersion($element->nodeValue
)) {
218 throw new PackageValidationException(PackageValidationException
::INVALID_PACKAGE_VERSION
, ['packageVersion' => $element->nodeValue
]);
221 $this->packageInfo
['version'] = $element->nodeValue
;
225 DateUtil
::validateDate($element->nodeValue
);
227 $this->packageInfo
['date'] = @strtotime
($element->nodeValue
);
232 // get author information
233 $authorInformation = $xpath->query('./ns:authorinformation', $package)->item(0);
234 $elements = $xpath->query('child::*', $authorInformation);
235 foreach ($elements as $element) {
236 $tagName = ($element->tagName
== 'authorurl') ?
'authorURL' : $element->tagName
;
237 $this->authorInfo
[$tagName] = $element->nodeValue
;
240 // get required packages
241 $elements = $xpath->query('child::ns:requiredpackages/ns:requiredpackage', $package);
242 foreach ($elements as $element) {
243 if (!Package
::isValidPackageName($element->nodeValue
)) {
244 throw new PackageValidationException(PackageValidationException
::INVALID_PACKAGE_NAME
, ['packageName' => $element->nodeValue
]);
248 $data = ['name' => $element->nodeValue
];
249 $attributes = $xpath->query('attribute::*', $element);
250 foreach ($attributes as $attribute) {
251 $data[$attribute->name
] = $attribute->value
;
254 $this->requirements
[$element->nodeValue
] = $data;
257 // get optional packages
258 $elements = $xpath->query('child::ns:optionalpackages/ns:optionalpackage', $package);
259 foreach ($elements as $element) {
260 if (!Package
::isValidPackageName($element->nodeValue
)) {
261 throw new PackageValidationException(PackageValidationException
::INVALID_PACKAGE_NAME
, ['packageName' => $element->nodeValue
]);
265 $data = ['name' => $element->nodeValue
];
266 $attributes = $xpath->query('attribute::*', $element);
267 foreach ($attributes as $attribute) {
268 $data[$attribute->name
] = $attribute->value
;
271 $this->optionals
[] = $data;
274 // get excluded packages
275 $elements = $xpath->query('child::ns:excludedpackages/ns:excludedpackage', $package);
276 foreach ($elements as $element) {
277 if (!Package
::isValidPackageName($element->nodeValue
)) {
278 throw new PackageValidationException(PackageValidationException
::INVALID_PACKAGE_NAME
, ['packageName' => $element->nodeValue
]);
282 $data = ['name' => $element->nodeValue
];
283 $attributes = $xpath->query('attribute::*', $element);
284 foreach ($attributes as $attribute) {
285 $data[$attribute->name
] = $attribute->value
;
288 $this->excludedPackages
[] = $data;
291 // get api compatibility
292 $elements = $xpath->query('child::ns:compatibility/ns:api', $package);
293 foreach ($elements as $element) {
294 if (!$element->hasAttribute('version')) continue;
296 $version = $element->getAttribute('version');
297 if (!preg_match('~^(?:201[7-9]|20[2-9][0-9])$~', $version)) {
298 throw new PackageValidationException(PackageValidationException
::INVALID_API_VERSION
, ['version' => $version]);
301 $this->compatibility
[] = $version;
305 $elements = $xpath->query('./ns:instructions', $package);
306 foreach ($elements as $element) {
307 $instructionData = [];
308 $instructions = $xpath->query('./ns:instruction', $element);
309 /** @var \DOMElement $instruction */
310 foreach ($instructions as $instruction) {
312 $attributes = $xpath->query('attribute::*', $instruction);
313 foreach ($attributes as $attribute) {
314 $data[$attribute->name
] = $attribute->value
;
317 $instructionData[] = [
318 'attributes' => $data,
319 'pip' => $instruction->getAttribute('type'),
320 'value' => $instruction->nodeValue
324 $fromVersion = $element->getAttribute('fromversion');
325 $type = $element->getAttribute('type');
327 if ($type == 'install') {
328 $this->instructions
['install'] = $instructionData;
331 $this->instructions
['update'][$fromVersion] = $instructionData;
335 // add com.woltlab.wcf to package requirements
336 if (!isset($this->requirements
['com.woltlab.wcf']) && $this->packageInfo
['name'] != 'com.woltlab.wcf') {
337 $this->requirements
['com.woltlab.wcf'] = ['name' => 'com.woltlab.wcf'];
340 if ($this->package
!= null) {
341 $this->filterUpdateInstructions();
344 // set default values
345 if (!isset($this->packageInfo
['isApplication'])) $this->packageInfo
['isApplication'] = 0;
346 if (!isset($this->packageInfo
['packageURL'])) $this->packageInfo
['packageURL'] = '';
350 * Filters update instructions.
352 protected function filterUpdateInstructions() {
353 $validFromVersion = null;
354 foreach ($this->instructions
['update'] as $fromVersion => $update) {
355 if (Package
::checkFromversion($this->package
->packageVersion
, $fromVersion)) {
356 $validFromVersion = $fromVersion;
361 if ($validFromVersion === null) {
362 $this->instructions
['update'] = [];
365 $this->instructions
['update'] = $this->instructions
['update'][$validFromVersion];
370 * Downloads the package archive.
372 * @return string path to the dowloaded file
374 public function downloadArchive() {
377 // file transfer via hypertext transfer protocol.
378 $this->archive
= FileUtil
::downloadFileFromHttp($this->archive
, $prefix);
381 $this->archive
= self
::unzipPackageArchive($this->archive
);
383 return $this->archive
;
387 * Closes and deletes the tar archive of this package.
389 public function deleteArchive() {
390 if ($this->tar
instanceof Tar
) {
394 @unlink
($this->archive
);
398 * Returns true if the package archive supports a new installation.
402 public function isValidInstall() {
403 return !empty($this->instructions
['install']);
407 * Checks if the new package is compatible with
408 * the package that is about to be updated.
410 * @param Package $package
411 * @return boolean isValidUpdate
413 public function isValidUpdate(Package
$package = null) {
414 if ($this->package
=== null && $package !== null) {
415 $this->setPackage($package);
417 // re-evaluate update data
418 $this->filterUpdateInstructions();
421 // Check name of the installed package against the name of the update. Both must be identical.
422 if ($this->packageInfo
['name'] != $this->package
->package
) {
426 // Check if the version number of the installed package is lower than the version number to which
427 // it's about to be updated.
428 if (Package
::compareVersion($this->packageInfo
['version'], $this->package
->packageVersion
) != 1) {
432 // Check if the package provides an instructions block for the update from the installed package version
433 if (empty($this->instructions
['update'])) {
441 * Checks if the current package is already installed, as it is not
442 * possible to install non-applications multiple times within the
447 public function isAlreadyInstalled() {
448 $sql = "SELECT COUNT(*)
449 FROM wcf".WCF_N
."_package
451 $statement = WCF
::getDB()->prepareStatement($sql);
452 $statement->execute([$this->packageInfo
['name']]);
454 return $statement->fetchSingleColumn() > 0;
458 * Returns true if the package is an application and has an unique abbreviation.
462 public function hasUniqueAbbreviation() {
463 if (!$this->packageInfo
['isApplication']) {
467 $sql = "SELECT COUNT(*)
468 FROM wcf".WCF_N
."_package
469 WHERE isApplication = ?
471 $statement = WCF
::getDB()->prepareStatement($sql);
472 $statement->execute([
474 '%.'.Package
::getAbbreviation($this->packageInfo
['name'])
477 return $statement->fetchSingleColumn() > 0;
481 * Returns information about the author of this package archive.
483 * @param string $name name of the requested information
486 public function getAuthorInfo($name) {
487 if (isset($this->authorInfo
[$name])) return $this->authorInfo
[$name];
492 * Returns information about this package.
494 * @param string $name name of the requested information
497 public function getPackageInfo($name) {
498 if (isset($this->packageInfo
[$name])) return $this->packageInfo
[$name];
503 * Returns a localized information about this package.
505 * @param string $name
508 public function getLocalizedPackageInfo($name) {
509 if (isset($this->packageInfo
[$name][WCF
::getLanguage()->getFixedLanguageCode()])) {
510 return $this->packageInfo
[$name][WCF
::getLanguage()->getFixedLanguageCode()];
512 else if (isset($this->packageInfo
[$name]['default'])) {
513 return $this->packageInfo
[$name]['default'];
516 if (!empty($this->packageInfo
[$name])) {
517 return reset($this->packageInfo
[$name]);
524 * Returns a list of all requirements of this package.
528 public function getRequirements() {
529 return $this->requirements
;
533 * Returns a list of all delivered optional packages of this package.
537 public function getOptionals() {
538 return $this->optionals
;
542 * Returns a list of excluded packages.
546 public function getExcludedPackages() {
547 return $this->excludedPackages
;
551 * Returns the list of compatible API versions.
555 public function getCompatibleVersions() {
556 return $this->compatibility
;
560 * Returns the package installation instructions.
564 public function getInstallInstructions() {
565 return $this->instructions
['install'];
569 * Returns the package update instructions.
573 public function getUpdateInstructions() {
574 return $this->instructions
['update'];
578 * Checks which package requirements do already exist in right version.
579 * Returns a list with all existing requirements.
583 public function getAllExistingRequirements() {
584 $existingRequirements = [];
585 $existingPackages = [];
586 if ($this->package
!== null) {
587 $sql = "SELECT package.*
588 FROM wcf".WCF_N
."_package_requirement requirement
589 LEFT JOIN wcf".WCF_N
."_package package
590 ON (package.packageID = requirement.requirement)
591 WHERE requirement.packageID = ?";
592 $statement = WCF
::getDB()->prepareStatement($sql);
593 $statement->execute([$this->package
->packageID
]);
594 while ($row = $statement->fetchArray()) {
595 $existingRequirements[$row['package']] = $row;
601 $requirements = $this->getRequirements();
602 foreach ($requirements as $requirement) {
603 if (isset($existingRequirements[$requirement['name']])) {
604 $existingPackages[$requirement['name']] = [];
605 $existingPackages[$requirement['name']][$existingRequirements[$requirement['name']]['packageID']] = $existingRequirements[$requirement['name']];
608 $packageNames[] = $requirement['name'];
612 // check whether the required packages do already exist
613 if (!empty($packageNames)) {
614 $conditions = new PreparedStatementConditionBuilder();
615 $conditions->add("package.package IN (?)", [$packageNames]);
617 $sql = "SELECT package.*
618 FROM wcf".WCF_N
."_package package
620 $statement = WCF
::getDB()->prepareStatement($sql);
621 $statement->execute($conditions->getParameters());
622 while ($row = $statement->fetchArray()) {
623 // check required package version
624 if (isset($requirements[$row['package']]['minversion']) && Package
::compareVersion($row['packageVersion'], $requirements[$row['package']]['minversion']) == -1) {
628 if (!isset($existingPackages[$row['package']])) {
629 $existingPackages[$row['package']] = [];
632 $existingPackages[$row['package']][$row['packageID']] = $row;
636 return $existingPackages;
640 * Checks which package requirements do already exist in database.
641 * Returns a list with the existing requirements.
645 public function getExistingRequirements() {
648 foreach ($this->requirements
as $requirement) {
649 $packageNames[] = $requirement['name'];
652 // check whether the required packages do already exist
653 $existingPackages = [];
654 if (!empty($packageNames)) {
655 $conditions = new PreparedStatementConditionBuilder();
656 $conditions->add("package IN (?)", [$packageNames]);
659 FROM wcf".WCF_N
."_package
661 $statement = WCF
::getDB()->prepareStatement($sql);
662 $statement->execute($conditions->getParameters());
663 while ($row = $statement->fetchArray()) {
664 if (!isset($existingPackages[$row['package']])) {
665 $existingPackages[$row['package']] = [];
668 $existingPackages[$row['package']][$row['packageVersion']] = $row;
671 // sort multiple packages by version number
672 foreach ($existingPackages as $packageName => $instances) {
673 uksort($instances, [Package
::class, 'compareVersion']);
675 // get package with highest version number (get last package)
676 $existingPackages[$packageName] = array_pop($instances);
680 return $existingPackages;
684 * Returns a list of all open requirements of this package.
688 public function getOpenRequirements() {
689 // get all existing requirements
690 $existingPackages = $this->getExistingRequirements();
692 // check for open requirements
693 $openRequirements = [];
694 foreach ($this->requirements
as $requirement) {
695 if (isset($existingPackages[$requirement['name']])) {
696 // package does already exist
697 // maybe an update is necessary
698 if (isset($requirement['minversion'])) {
699 if (Package
::compareVersion($existingPackages[$requirement['name']]['packageVersion'], $requirement['minversion']) >= 0) {
700 // package does already exist in needed version
701 // skip installation of requirement
705 $requirement['existingVersion'] = $existingPackages[$requirement['name']]['packageVersion'];
712 $requirement['packageID'] = $existingPackages[$requirement['name']]['packageID'];
713 $requirement['action'] = 'update';
716 // package does not exist
717 // new installation is necessary
718 $requirement['packageID'] = 0;
719 $requirement['action'] = 'install';
722 $openRequirements[$requirement['name']] = $requirement;
725 return $openRequirements;
729 * Extracts the requested file in the package archive to the temp folder
730 * and returns the path to the extracted file.
732 * @param string $filename
733 * @param string $tempPrefix
735 * @throws PackageValidationException
737 public function extractTar($filename, $tempPrefix = 'package_') {
738 // search the requested tar archive in our package archive.
739 // throw error message if not found.
740 if (($fileIndex = $this->tar
->getIndexByFilename($filename)) === false) {
741 throw new PackageValidationException(PackageValidationException
::FILE_NOT_FOUND
, [
742 'archive' => $this->archive
,
743 'targetArchive' => $filename
747 // requested tar archive was found
748 $fileInfo = $this->tar
->getFileInfo($fileIndex);
749 $filename = FileUtil
::getTemporaryFilename($tempPrefix, preg_replace('!^.*?(\.(?:tar\.gz|tgz|tar))$!i', '\\1', $fileInfo['filename']));
750 $this->tar
->extract($fileIndex, $filename);
756 * Unzips compressed package archives and returns the temporary file name.
758 * @param string $archive filename
761 public static function unzipPackageArchive($archive) {
762 if (!FileUtil
::isURL($archive)) {
763 $tar = new Tar($archive);
765 if ($tar->isZipped()) {
766 $tmpName = FileUtil
::getTemporaryFilename('package_');
767 if (FileUtil
::uncompressFile($archive, $tmpName)) {
777 * Returns a list of packages which exclude this package.
781 public function getConflictedExcludingPackages() {
782 $conflictedPackages = [];
783 $sql = "SELECT package.*, package_exclusion.*
784 FROM wcf".WCF_N
."_package_exclusion package_exclusion
785 LEFT JOIN wcf".WCF_N
."_package package
786 ON (package.packageID = package_exclusion.packageID)
787 WHERE excludedPackage = ?";
788 $statement = WCF
::getDB()->prepareStatement($sql);
789 $statement->execute([$this->packageInfo
['name']]);
790 while ($row = $statement->fetchArray()) {
791 if (!empty($row['excludedPackageVersion'])) {
792 if (Package
::compareVersion($this->packageInfo
['version'], $row['excludedPackageVersion'], '<')) {
797 $conflictedPackages[$row['packageID']] = new Package(null, $row);
800 return $conflictedPackages;
804 * Returns a list of packages which are excluded by this package.
808 public function getConflictedExcludedPackages() {
809 $conflictedPackages = [];
810 if (!empty($this->excludedPackages
)) {
811 $excludedPackages = [];
812 foreach ($this->excludedPackages
as $excludedPackageData) {
813 $excludedPackages[$excludedPackageData['name']] = $excludedPackageData['version'];
816 $conditions = new PreparedStatementConditionBuilder();
817 $conditions->add("package IN (?)", [array_keys($excludedPackages)]);
820 FROM wcf".WCF_N
."_package
822 $statement = WCF
::getDB()->prepareStatement($sql);
823 $statement->execute($conditions->getParameters());
824 while ($row = $statement->fetchArray()) {
825 if (!empty($excludedPackages[$row['package']])) {
826 if (Package
::compareVersion($row['packageVersion'], $excludedPackages[$row['package']], '<')) {
829 $row['excludedPackageVersion'] = $excludedPackages[$row['package']];
832 $conflictedPackages[$row['packageID']] = new Package(null, $row);
836 return $conflictedPackages;
840 * Returns a list of instructions for installation or update.
842 * @param string $type
845 public function getInstructions($type) {
846 if (isset($this->instructions
[$type])) {
847 return $this->instructions
[$type];
854 * Returns a list of php requirements for current package.
859 public function getPhpRequirements() {