Merge branch '2.1' into 3.0
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / package / PackageArchive.class.php
1 <?php
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;
6 use wcf\system\io\Tar;
7 use wcf\system\WCF;
8 use wcf\util\DateUtil;
9 use wcf\util\FileUtil;
10 use wcf\util\XML;
11
12 /**
13 * Represents the archive of a package.
14 *
15 * @author Marcel Werk
16 * @copyright 2001-2017 WoltLab GmbH
17 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
18 * @package WoltLabSuite\Core\System\Package
19 */
20 class PackageArchive {
21 /**
22 * path to package archive
23 * @var string
24 */
25 protected $archive = null;
26
27 /**
28 * package object of an existing package
29 * @var Package
30 */
31 protected $package = null;
32
33 /**
34 * tar archive object
35 * @var Tar
36 */
37 protected $tar = null;
38
39 /**
40 * general package information
41 * @var array
42 */
43 protected $packageInfo = [];
44
45 /**
46 * author information
47 * @var array
48 */
49 protected $authorInfo = [];
50
51 /**
52 * list of requirements
53 * @var array
54 */
55 protected $requirements = [];
56
57 /**
58 * list of optional packages
59 * @var array
60 */
61 protected $optionals = [];
62
63 /**
64 * list of excluded packages
65 * @var array
66 */
67 protected $excludedPackages = [];
68
69 /**
70 * list of instructions
71 * @var mixed[][]
72 */
73 protected $instructions = [
74 'install' => [],
75 'update' => []
76 ];
77
78 /**
79 * default name of the package.xml file
80 * @var string
81 */
82 const INFO_FILE = 'package.xml';
83
84 /**
85 * Creates a new PackageArchive object.
86 *
87 * @param string $archive
88 * @param Package $package
89 */
90 public function __construct($archive, Package $package = null) {
91 $this->archive = $archive; // be careful: this is a string within this class,
92 // but an object in the packageStartInstallForm.class!
93 $this->package = $package;
94 }
95
96 /**
97 * Sets associated package object.
98 *
99 * @param Package $package
100 */
101 public function setPackage(Package $package) {
102 $this->package = $package;
103 }
104
105 /**
106 * Returns the name of the package archive.
107 *
108 * @return string
109 */
110 public function getArchive() {
111 return $this->archive;
112 }
113
114 /**
115 * Returns the object of the package archive.
116 *
117 * @return Tar
118 */
119 public function getTar() {
120 return $this->tar;
121 }
122
123 /**
124 * Opens the package archive and reads package information.
125 */
126 public function openArchive() {
127 // check whether archive exists and is a TAR archive
128 if (!file_exists($this->archive)) {
129 throw new PackageValidationException(PackageValidationException::FILE_NOT_FOUND, ['archive' => $this->archive]);
130 }
131
132 // open archive and read package information
133 $this->tar = new Tar($this->archive);
134 $this->readPackageInfo();
135 }
136
137 /**
138 * Extracts information about this package (parses package.xml).
139 */
140 protected function readPackageInfo() {
141 // search package.xml in package archive
142 // throw error message if not found
143 if ($this->tar->getIndexByFilename(self::INFO_FILE) === false) {
144 throw new PackageValidationException(PackageValidationException::MISSING_PACKAGE_XML, ['archive' => $this->archive]);
145 }
146
147 // extract package.xml, parse XML
148 // and compile an array with XML::getElementTree()
149 $xml = new XML();
150 try {
151 $xml->loadXML(self::INFO_FILE, $this->tar->extractToString(self::INFO_FILE));
152 }
153 catch (\Exception $e) { // bugfix to avoid file caching problems
154 $xml->loadXML(self::INFO_FILE, $this->tar->extractToString(self::INFO_FILE));
155 }
156
157 // parse xml
158 $xpath = $xml->xpath();
159 /** @var \DOMElement $package */
160 $package = $xpath->query('/ns:package')->item(0);
161
162 // package name
163 $packageName = $package->getAttribute('name');
164 if (!Package::isValidPackageName($packageName)) {
165 // package name is not a valid package identifier
166 throw new PackageValidationException(PackageValidationException::INVALID_PACKAGE_NAME, ['packageName' => $packageName]);
167 }
168
169 $this->packageInfo['name'] = $packageName;
170
171 // get package information
172 $packageInformation = $xpath->query('./ns:packageinformation', $package)->item(0);
173 $elements = $xpath->query('child::*', $packageInformation);
174 /** @var \DOMElement $element */
175 foreach ($elements as $element) {
176 switch ($element->tagName) {
177 case 'packagename':
178 case 'packagedescription':
179 case 'readme':
180 case 'license':
181 if (!isset($this->packageInfo[$element->tagName])) $this->packageInfo[$element->tagName] = [];
182
183 $languageCode = 'default';
184 if ($element->hasAttribute('language')) {
185 $languageCode = $element->getAttribute('language');
186 }
187
188 // fix case-sensitive names
189 $name = $element->tagName;
190 if ($name == 'packagename') $name = 'packageName';
191 else if ($name == 'packagedescription') $name = 'packageDescription';
192
193 $this->packageInfo[$name][$languageCode] = $element->nodeValue;
194 break;
195
196 case 'isapplication':
197 $this->packageInfo['isApplication'] = intval($element->nodeValue);
198 break;
199
200 case 'applicationdirectory':
201 if (preg_match('~^[a-z0-9\-\_]+$~', $element->nodeValue)) {
202 $this->packageInfo['applicationDirectory'] = $element->nodeValue;
203 }
204 break;
205
206 case 'packageurl':
207 $this->packageInfo['packageURL'] = $element->nodeValue;
208 break;
209
210 case 'version':
211 if (!Package::isValidVersion($element->nodeValue)) {
212 throw new PackageValidationException(PackageValidationException::INVALID_PACKAGE_VERSION, ['packageVersion' => $element->nodeValue]);
213 }
214
215 $this->packageInfo['version'] = $element->nodeValue;
216 break;
217
218 case 'date':
219 DateUtil::validateDate($element->nodeValue);
220
221 $this->packageInfo['date'] = @strtotime($element->nodeValue);
222 break;
223 }
224 }
225
226 // get author information
227 $authorInformation = $xpath->query('./ns:authorinformation', $package)->item(0);
228 $elements = $xpath->query('child::*', $authorInformation);
229 foreach ($elements as $element) {
230 $tagName = ($element->tagName == 'authorurl') ? 'authorURL' : $element->tagName;
231 $this->authorInfo[$tagName] = $element->nodeValue;
232 }
233
234 // get required packages
235 $elements = $xpath->query('child::ns:requiredpackages/ns:requiredpackage', $package);
236 foreach ($elements as $element) {
237 if (!Package::isValidPackageName($element->nodeValue)) {
238 throw new PackageValidationException(PackageValidationException::INVALID_PACKAGE_NAME, ['packageName' => $element->nodeValue]);
239 }
240
241 // read attributes
242 $data = ['name' => $element->nodeValue];
243 $attributes = $xpath->query('attribute::*', $element);
244 foreach ($attributes as $attribute) {
245 $data[$attribute->name] = $attribute->value;
246 }
247
248 $this->requirements[$element->nodeValue] = $data;
249 }
250
251 // get optional packages
252 $elements = $xpath->query('child::ns:optionalpackages/ns:optionalpackage', $package);
253 foreach ($elements as $element) {
254 if (!Package::isValidPackageName($element->nodeValue)) {
255 throw new PackageValidationException(PackageValidationException::INVALID_PACKAGE_NAME, ['packageName' => $element->nodeValue]);
256 }
257
258 // read attributes
259 $data = ['name' => $element->nodeValue];
260 $attributes = $xpath->query('attribute::*', $element);
261 foreach ($attributes as $attribute) {
262 $data[$attribute->name] = $attribute->value;
263 }
264
265 $this->optionals[] = $data;
266 }
267
268 // get excluded packages
269 $elements = $xpath->query('child::ns:excludedpackages/ns:excludedpackage', $package);
270 foreach ($elements as $element) {
271 if (!Package::isValidPackageName($element->nodeValue)) {
272 throw new PackageValidationException(PackageValidationException::INVALID_PACKAGE_NAME, ['packageName' => $element->nodeValue]);
273 }
274
275 // read attributes
276 $data = ['name' => $element->nodeValue];
277 $attributes = $xpath->query('attribute::*', $element);
278 foreach ($attributes as $attribute) {
279 $data[$attribute->name] = $attribute->value;
280 }
281
282 $this->excludedPackages[] = $data;
283 }
284
285 // get instructions
286 $elements = $xpath->query('./ns:instructions', $package);
287 foreach ($elements as $element) {
288 $instructionData = [];
289 $instructions = $xpath->query('./ns:instruction', $element);
290 /** @var \DOMElement $instruction */
291 foreach ($instructions as $instruction) {
292 $data = [];
293 $attributes = $xpath->query('attribute::*', $instruction);
294 foreach ($attributes as $attribute) {
295 $data[$attribute->name] = $attribute->value;
296 }
297
298 $instructionData[] = [
299 'attributes' => $data,
300 'pip' => $instruction->getAttribute('type'),
301 'value' => $instruction->nodeValue
302 ];
303 }
304
305 $fromVersion = $element->getAttribute('fromversion');
306 $type = $element->getAttribute('type');
307
308 if ($type == 'install') {
309 $this->instructions['install'] = $instructionData;
310 }
311 else {
312 $this->instructions['update'][$fromVersion] = $instructionData;
313 }
314 }
315
316 // add com.woltlab.wcf to package requirements
317 if (!isset($this->requirements['com.woltlab.wcf']) && $this->packageInfo['name'] != 'com.woltlab.wcf') {
318 $this->requirements['com.woltlab.wcf'] = ['name' => 'com.woltlab.wcf'];
319 }
320
321 if ($this->package != null) {
322 $this->filterUpdateInstructions();
323 }
324
325 // set default values
326 if (!isset($this->packageInfo['isApplication'])) $this->packageInfo['isApplication'] = 0;
327 if (!isset($this->packageInfo['packageURL'])) $this->packageInfo['packageURL'] = '';
328 }
329
330 /**
331 * Filters update instructions.
332 */
333 protected function filterUpdateInstructions() {
334 $validFromVersion = null;
335 foreach ($this->instructions['update'] as $fromVersion => $update) {
336 if (Package::checkFromversion($this->package->packageVersion, $fromVersion)) {
337 $validFromVersion = $fromVersion;
338 break;
339 }
340 }
341
342 if ($validFromVersion === null) {
343 $this->instructions['update'] = [];
344 }
345 else {
346 $this->instructions['update'] = $this->instructions['update'][$validFromVersion];
347 }
348 }
349
350 /**
351 * Downloads the package archive.
352 *
353 * @return string path to the dowloaded file
354 */
355 public function downloadArchive() {
356 $prefix = 'package';
357
358 // file transfer via hypertext transfer protocol.
359 $this->archive = FileUtil::downloadFileFromHttp($this->archive, $prefix);
360
361 // unzip tar
362 $this->archive = self::unzipPackageArchive($this->archive);
363
364 return $this->archive;
365 }
366
367 /**
368 * Closes and deletes the tar archive of this package.
369 */
370 public function deleteArchive() {
371 if ($this->tar instanceof Tar) {
372 $this->tar->close();
373 }
374
375 @unlink($this->archive);
376 }
377
378 /**
379 * Returns true if the package archive supports a new installation.
380 *
381 * @return boolean
382 */
383 public function isValidInstall() {
384 return !empty($this->instructions['install']);
385 }
386
387 /**
388 * Checks if the new package is compatible with
389 * the package that is about to be updated.
390 *
391 * @param Package $package
392 * @return boolean isValidUpdate
393 */
394 public function isValidUpdate(Package $package = null) {
395 if ($this->package === null && $package !== null) {
396 $this->setPackage($package);
397
398 // re-evaluate update data
399 $this->filterUpdateInstructions();
400 }
401
402 // Check name of the installed package against the name of the update. Both must be identical.
403 if ($this->packageInfo['name'] != $this->package->package) {
404 return false;
405 }
406
407 // Check if the version number of the installed package is lower than the version number to which
408 // it's about to be updated.
409 if (Package::compareVersion($this->packageInfo['version'], $this->package->packageVersion) != 1) {
410 return false;
411 }
412
413 // Check if the package provides an instructions block for the update from the installed package version
414 if (empty($this->instructions['update'])) {
415 return false;
416 }
417
418 return true;
419 }
420
421 /**
422 * Checks if the current package is already installed, as it is not
423 * possible to install non-applications multiple times within the
424 * same environment.
425 *
426 * @return boolean
427 */
428 public function isAlreadyInstalled() {
429 $sql = "SELECT COUNT(*)
430 FROM wcf".WCF_N."_package
431 WHERE package = ?";
432 $statement = WCF::getDB()->prepareStatement($sql);
433 $statement->execute([$this->packageInfo['name']]);
434
435 return $statement->fetchSingleColumn() > 0;
436 }
437
438 /**
439 * Returns true if the package is an application and has an unique abbreviation.
440 *
441 * @return boolean
442 */
443 public function hasUniqueAbbreviation() {
444 if (!$this->packageInfo['isApplication']) {
445 return true;
446 }
447
448 $sql = "SELECT COUNT(*)
449 FROM wcf".WCF_N."_package
450 WHERE isApplication = ?
451 AND package LIKE ?";
452 $statement = WCF::getDB()->prepareStatement($sql);
453 $statement->execute([
454 1,
455 '%.'.Package::getAbbreviation($this->packageInfo['name'])
456 ]);
457
458 return $statement->fetchSingleColumn() > 0;
459 }
460
461 /**
462 * Returns information about the author of this package archive.
463 *
464 * @param string $name name of the requested information
465 * @return string
466 */
467 public function getAuthorInfo($name) {
468 if (isset($this->authorInfo[$name])) return $this->authorInfo[$name];
469 return null;
470 }
471
472 /**
473 * Returns information about this package.
474 *
475 * @param string $name name of the requested information
476 * @return mixed
477 */
478 public function getPackageInfo($name) {
479 if (isset($this->packageInfo[$name])) return $this->packageInfo[$name];
480 return null;
481 }
482
483 /**
484 * Returns a localized information about this package.
485 *
486 * @param string $name
487 * @return string
488 */
489 public function getLocalizedPackageInfo($name) {
490 if (isset($this->packageInfo[$name][WCF::getLanguage()->getFixedLanguageCode()])) {
491 return $this->packageInfo[$name][WCF::getLanguage()->getFixedLanguageCode()];
492 }
493 else if (isset($this->packageInfo[$name]['default'])) {
494 return $this->packageInfo[$name]['default'];
495 }
496
497 if (!empty($this->packageInfo[$name])) {
498 return reset($this->packageInfo[$name]);
499 }
500
501 return '';
502 }
503
504 /**
505 * Returns a list of all requirements of this package.
506 *
507 * @return array
508 */
509 public function getRequirements() {
510 return $this->requirements;
511 }
512
513 /**
514 * Returns a list of all delivered optional packages of this package.
515 *
516 * @return array
517 */
518 public function getOptionals() {
519 return $this->optionals;
520 }
521
522 /**
523 * Returns a list of excluded packages.
524 *
525 * @return array
526 */
527 public function getExcludedPackages() {
528 return $this->excludedPackages;
529 }
530
531 /**
532 * Returns the package installation instructions.
533 *
534 * @return array
535 */
536 public function getInstallInstructions() {
537 return $this->instructions['install'];
538 }
539
540 /**
541 * Returns the package update instructions.
542 *
543 * @return array
544 */
545 public function getUpdateInstructions() {
546 return $this->instructions['update'];
547 }
548
549 /**
550 * Checks which package requirements do already exist in right version.
551 * Returns a list with all existing requirements.
552 *
553 * @return array
554 */
555 public function getAllExistingRequirements() {
556 $existingRequirements = [];
557 $existingPackages = [];
558 if ($this->package !== null) {
559 $sql = "SELECT package.*
560 FROM wcf".WCF_N."_package_requirement requirement
561 LEFT JOIN wcf".WCF_N."_package package
562 ON (package.packageID = requirement.requirement)
563 WHERE requirement.packageID = ?";
564 $statement = WCF::getDB()->prepareStatement($sql);
565 $statement->execute([$this->package->packageID]);
566 while ($row = $statement->fetchArray()) {
567 $existingRequirements[$row['package']] = $row;
568 }
569 }
570
571 // build sql
572 $packageNames = [];
573 $requirements = $this->getRequirements();
574 foreach ($requirements as $requirement) {
575 if (isset($existingRequirements[$requirement['name']])) {
576 $existingPackages[$requirement['name']] = [];
577 $existingPackages[$requirement['name']][$existingRequirements[$requirement['name']]['packageID']] = $existingRequirements[$requirement['name']];
578 }
579 else {
580 $packageNames[] = $requirement['name'];
581 }
582 }
583
584 // check whether the required packages do already exist
585 if (!empty($packageNames)) {
586 $conditions = new PreparedStatementConditionBuilder();
587 $conditions->add("package.package IN (?)", [$packageNames]);
588
589 $sql = "SELECT package.*
590 FROM wcf".WCF_N."_package package
591 ".$conditions;
592 $statement = WCF::getDB()->prepareStatement($sql);
593 $statement->execute($conditions->getParameters());
594 while ($row = $statement->fetchArray()) {
595 // check required package version
596 if (isset($requirements[$row['package']]['minversion']) && Package::compareVersion($row['packageVersion'], $requirements[$row['package']]['minversion']) == -1) {
597 continue;
598 }
599
600 if (!isset($existingPackages[$row['package']])) {
601 $existingPackages[$row['package']] = [];
602 }
603
604 $existingPackages[$row['package']][$row['packageID']] = $row;
605 }
606 }
607
608 return $existingPackages;
609 }
610
611 /**
612 * Checks which package requirements do already exist in database.
613 * Returns a list with the existing requirements.
614 *
615 * @return array
616 */
617 public function getExistingRequirements() {
618 // build sql
619 $packageNames = [];
620 foreach ($this->requirements as $requirement) {
621 $packageNames[] = $requirement['name'];
622 }
623
624 // check whether the required packages do already exist
625 $existingPackages = [];
626 if (!empty($packageNames)) {
627 $conditions = new PreparedStatementConditionBuilder();
628 $conditions->add("package IN (?)", [$packageNames]);
629
630 $sql = "SELECT *
631 FROM wcf".WCF_N."_package
632 ".$conditions;
633 $statement = WCF::getDB()->prepareStatement($sql);
634 $statement->execute($conditions->getParameters());
635 while ($row = $statement->fetchArray()) {
636 if (!isset($existingPackages[$row['package']])) {
637 $existingPackages[$row['package']] = [];
638 }
639
640 $existingPackages[$row['package']][$row['packageVersion']] = $row;
641 }
642
643 // sort multiple packages by version number
644 foreach ($existingPackages as $packageName => $instances) {
645 uksort($instances, [Package::class, 'compareVersion']);
646
647 // get package with highest version number (get last package)
648 $existingPackages[$packageName] = array_pop($instances);
649 }
650 }
651
652 return $existingPackages;
653 }
654
655 /**
656 * Returns a list of all open requirements of this package.
657 *
658 * @return array
659 */
660 public function getOpenRequirements() {
661 // get all existing requirements
662 $existingPackages = $this->getExistingRequirements();
663
664 // check for open requirements
665 $openRequirements = [];
666 foreach ($this->requirements as $requirement) {
667 if (isset($existingPackages[$requirement['name']])) {
668 // package does already exist
669 // maybe an update is necessary
670 if (isset($requirement['minversion'])) {
671 if (Package::compareVersion($existingPackages[$requirement['name']]['packageVersion'], $requirement['minversion']) >= 0) {
672 // package does already exist in needed version
673 // skip installation of requirement
674 continue;
675 }
676 else {
677 $requirement['existingVersion'] = $existingPackages[$requirement['name']]['packageVersion'];
678 }
679 }
680 else {
681 continue;
682 }
683
684 $requirement['packageID'] = $existingPackages[$requirement['name']]['packageID'];
685 $requirement['action'] = 'update';
686 }
687 else {
688 // package does not exist
689 // new installation is necessary
690 $requirement['packageID'] = 0;
691 $requirement['action'] = 'install';
692 }
693
694 $openRequirements[$requirement['name']] = $requirement;
695 }
696
697 return $openRequirements;
698 }
699
700 /**
701 * Extracts the requested file in the package archive to the temp folder
702 * and returns the path to the extracted file.
703 *
704 * @param string $filename
705 * @param string $tempPrefix
706 * @return string
707 * @throws PackageValidationException
708 */
709 public function extractTar($filename, $tempPrefix = 'package_') {
710 // search the requested tar archive in our package archive.
711 // throw error message if not found.
712 if (($fileIndex = $this->tar->getIndexByFilename($filename)) === false) {
713 throw new PackageValidationException(PackageValidationException::FILE_NOT_FOUND, [
714 'archive' => $this->archive,
715 'targetArchive' => $filename
716 ]);
717 }
718
719 // requested tar archive was found
720 $fileInfo = $this->tar->getFileInfo($fileIndex);
721 $filename = FileUtil::getTemporaryFilename($tempPrefix, preg_replace('!^.*?(\.(?:tar\.gz|tgz|tar))$!i', '\\1', $fileInfo['filename']));
722 $this->tar->extract($fileIndex, $filename);
723
724 return $filename;
725 }
726
727 /**
728 * Unzips compressed package archives and returns the temporary file name.
729 *
730 * @param string $archive filename
731 * @return string
732 */
733 public static function unzipPackageArchive($archive) {
734 if (!FileUtil::isURL($archive)) {
735 $tar = new Tar($archive);
736 $tar->close();
737 if ($tar->isZipped()) {
738 $tmpName = FileUtil::getTemporaryFilename('package_');
739 if (FileUtil::uncompressFile($archive, $tmpName)) {
740 return $tmpName;
741 }
742 }
743 }
744
745 return $archive;
746 }
747
748 /**
749 * Returns a list of packages which exclude this package.
750 *
751 * @return Package[]
752 */
753 public function getConflictedExcludingPackages() {
754 $conflictedPackages = [];
755 $sql = "SELECT package.*, package_exclusion.*
756 FROM wcf".WCF_N."_package_exclusion package_exclusion
757 LEFT JOIN wcf".WCF_N."_package package
758 ON (package.packageID = package_exclusion.packageID)
759 WHERE excludedPackage = ?";
760 $statement = WCF::getDB()->prepareStatement($sql);
761 $statement->execute([$this->packageInfo['name']]);
762 while ($row = $statement->fetchArray()) {
763 if (!empty($row['excludedPackageVersion'])) {
764 if (Package::compareVersion($this->packageInfo['version'], $row['excludedPackageVersion'], '<')) {
765 continue;
766 }
767 }
768
769 $conflictedPackages[$row['packageID']] = new Package(null, $row);
770 }
771
772 return $conflictedPackages;
773 }
774
775 /**
776 * Returns a list of packages which are excluded by this package.
777 *
778 * @return Package[]
779 */
780 public function getConflictedExcludedPackages() {
781 $conflictedPackages = [];
782 if (!empty($this->excludedPackages)) {
783 $excludedPackages = [];
784 foreach ($this->excludedPackages as $excludedPackageData) {
785 $excludedPackages[$excludedPackageData['name']] = $excludedPackageData['version'];
786 }
787
788 $conditions = new PreparedStatementConditionBuilder();
789 $conditions->add("package IN (?)", [array_keys($excludedPackages)]);
790
791 $sql = "SELECT *
792 FROM wcf".WCF_N."_package
793 ".$conditions;
794 $statement = WCF::getDB()->prepareStatement($sql);
795 $statement->execute($conditions->getParameters());
796 while ($row = $statement->fetchArray()) {
797 if (!empty($excludedPackages[$row['package']])) {
798 if (Package::compareVersion($row['packageVersion'], $excludedPackages[$row['package']], '<')) {
799 continue;
800 }
801 $row['excludedPackageVersion'] = $excludedPackages[$row['package']];
802 }
803
804 $conflictedPackages[$row['packageID']] = new Package(null, $row);
805 }
806 }
807
808 return $conflictedPackages;
809 }
810
811 /**
812 * Returns a list of instructions for installation or update.
813 *
814 * @param string $type
815 * @return array
816 */
817 public function getInstructions($type) {
818 if (isset($this->instructions[$type])) {
819 return $this->instructions[$type];
820 }
821
822 return null;
823 }
824
825 /**
826 * Returns a list of php requirements for current package.
827 *
828 * @return mixed[][]
829 * @deprecated 3.0
830 */
831 public function getPhpRequirements() {
832 return [];
833 }
834 }