1<?php 2 3/** 4 * DokuWiki Plugin extension (Helper Component) 5 * 6 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 7 * @author Michael Hamann <michael@content-space.de> 8 */ 9 10use dokuwiki\Extension\Plugin; 11use dokuwiki\Extension\PluginInterface; 12use dokuwiki\Utf8\PhpString; 13use splitbrain\PHPArchive\Tar; 14use splitbrain\PHPArchive\ArchiveIOException; 15use splitbrain\PHPArchive\Zip; 16use dokuwiki\HTTP\DokuHTTPClient; 17use dokuwiki\Extension\PluginController; 18 19/** 20 * Class helper_plugin_extension_extension represents a single extension (plugin or template) 21 */ 22class helper_plugin_extension_extension extends Plugin 23{ 24 private $id; 25 private $base; 26 private $is_template = false; 27 private $localInfo; 28 private $remoteInfo; 29 private $managerData; 30 /** @var helper_plugin_extension_repository $repository */ 31 private $repository; 32 33 /** @var array list of temporary directories */ 34 private $temporary = []; 35 36 /** @var string where templates are installed to */ 37 private $tpllib = ''; 38 39 /** 40 * helper_plugin_extension_extension constructor. 41 */ 42 public function __construct() 43 { 44 $this->tpllib = dirname(tpl_incdir()) . '/'; 45 } 46 47 /** 48 * Destructor 49 * 50 * deletes any dangling temporary directories 51 */ 52 public function __destruct() 53 { 54 foreach ($this->temporary as $dir) { 55 io_rmdir($dir, true); 56 } 57 } 58 59 /** 60 * @return bool false, this component is not a singleton 61 */ 62 public function isSingleton() 63 { 64 return false; 65 } 66 67 /** 68 * Set the name of the extension this instance shall represents, triggers loading the local and remote data 69 * 70 * @param string $id The id of the extension (prefixed with template: for templates) 71 * @return bool If some (local or remote) data was found 72 */ 73 public function setExtension($id) 74 { 75 $id = cleanID($id); 76 $this->id = $id; 77 78 $this->base = $id; 79 80 if (str_starts_with($id, 'template:')) { 81 $this->base = substr($id, 9); 82 $this->is_template = true; 83 } else { 84 $this->is_template = false; 85 } 86 87 $this->localInfo = []; 88 $this->managerData = []; 89 $this->remoteInfo = []; 90 91 if ($this->isInstalled()) { 92 $this->readLocalData(); 93 $this->readManagerData(); 94 } 95 96 if ($this->repository == null) { 97 $this->repository = $this->loadHelper('extension_repository'); 98 } 99 100 $this->remoteInfo = $this->repository->getData($this->getID()); 101 102 return ($this->localInfo || $this->remoteInfo); 103 } 104 105 /** 106 * If the extension is installed locally 107 * 108 * @return bool If the extension is installed locally 109 */ 110 public function isInstalled() 111 { 112 return is_dir($this->getInstallDir()); 113 } 114 115 /** 116 * If the extension is under git control 117 * 118 * @return bool 119 */ 120 public function isGitControlled() 121 { 122 if (!$this->isInstalled()) return false; 123 return file_exists($this->getInstallDir() . '/.git'); 124 } 125 126 /** 127 * If the extension is bundled 128 * 129 * @return bool If the extension is bundled 130 */ 131 public function isBundled() 132 { 133 if (!empty($this->remoteInfo['bundled'])) return $this->remoteInfo['bundled']; 134 return in_array( 135 $this->id, 136 [ 137 'authad', 138 'authldap', 139 'authpdo', 140 'authplain', 141 'acl', 142 'config', 143 'extension', 144 'info', 145 'popularity', 146 'revert', 147 'safefnrecode', 148 'styling', 149 'testing', 150 'usermanager', 151 'logviewer', 152 'template:dokuwiki' 153 ] 154 ); 155 } 156 157 /** 158 * If the extension is protected against any modification (disable/uninstall) 159 * 160 * @return bool if the extension is protected 161 */ 162 public function isProtected() 163 { 164 // never allow deinstalling the current auth plugin: 165 global $conf; 166 if ($this->id == $conf['authtype']) return true; 167 168 /** @var PluginController $plugin_controller */ 169 global $plugin_controller; 170 $cascade = $plugin_controller->getCascade(); 171 return (isset($cascade['protected'][$this->id]) && $cascade['protected'][$this->id]); 172 } 173 174 /** 175 * If the extension is installed in the correct directory 176 * 177 * @return bool If the extension is installed in the correct directory 178 */ 179 public function isInWrongFolder() 180 { 181 return $this->base != $this->getBase(); 182 } 183 184 /** 185 * If the extension is enabled 186 * 187 * @return bool If the extension is enabled 188 */ 189 public function isEnabled() 190 { 191 global $conf; 192 if ($this->isTemplate()) { 193 return ($conf['template'] == $this->getBase()); 194 } 195 196 /* @var PluginController $plugin_controller */ 197 global $plugin_controller; 198 return $plugin_controller->isEnabled($this->base); 199 } 200 201 /** 202 * If the extension should be updated, i.e. if an updated version is available 203 * 204 * @return bool If an update is available 205 */ 206 public function updateAvailable() 207 { 208 if (!$this->isInstalled()) return false; 209 if ($this->isBundled()) return false; 210 $lastupdate = $this->getLastUpdate(); 211 if ($lastupdate === false) return false; 212 $installed = $this->getInstalledVersion(); 213 if ($installed === false || $installed === $this->getLang('unknownversion')) return true; 214 return $this->getInstalledVersion() < $this->getLastUpdate(); 215 } 216 217 /** 218 * If the extension is a template 219 * 220 * @return bool If this extension is a template 221 */ 222 public function isTemplate() 223 { 224 return $this->is_template; 225 } 226 227 /** 228 * Get the ID of the extension 229 * 230 * This is the same as getName() for plugins, for templates it's getName() prefixed with 'template:' 231 * 232 * @return string 233 */ 234 public function getID() 235 { 236 return $this->id; 237 } 238 239 /** 240 * Get the name of the installation directory 241 * 242 * @return string The name of the installation directory 243 */ 244 public function getInstallName() 245 { 246 return $this->base; 247 } 248 249 // Data from plugin.info.txt/template.info.txt or the repo when not available locally 250 /** 251 * Get the basename of the extension 252 * 253 * @return string The basename 254 */ 255 public function getBase() 256 { 257 if (!empty($this->localInfo['base'])) return $this->localInfo['base']; 258 return $this->base; 259 } 260 261 /** 262 * Get the display name of the extension 263 * 264 * @return string The display name 265 */ 266 public function getDisplayName() 267 { 268 if (!empty($this->localInfo['name'])) return $this->localInfo['name']; 269 if (!empty($this->remoteInfo['name'])) return $this->remoteInfo['name']; 270 return $this->base; 271 } 272 273 /** 274 * Get the author name of the extension 275 * 276 * @return string|bool The name of the author or false if there is none 277 */ 278 public function getAuthor() 279 { 280 if (!empty($this->localInfo['author'])) return $this->localInfo['author']; 281 if (!empty($this->remoteInfo['author'])) return $this->remoteInfo['author']; 282 return false; 283 } 284 285 /** 286 * Get the email of the author of the extension if there is any 287 * 288 * @return string|bool The email address or false if there is none 289 */ 290 public function getEmail() 291 { 292 // email is only in the local data 293 if (!empty($this->localInfo['email'])) return $this->localInfo['email']; 294 return false; 295 } 296 297 /** 298 * Get the email id, i.e. the md5sum of the email 299 * 300 * @return string|bool The md5sum of the email if there is any, false otherwise 301 */ 302 public function getEmailID() 303 { 304 if (!empty($this->remoteInfo['emailid'])) return $this->remoteInfo['emailid']; 305 if (!empty($this->localInfo['email'])) return md5($this->localInfo['email']); 306 return false; 307 } 308 309 /** 310 * Get the description of the extension 311 * 312 * @return string The description 313 */ 314 public function getDescription() 315 { 316 if (!empty($this->localInfo['desc'])) return $this->localInfo['desc']; 317 if (!empty($this->remoteInfo['description'])) return $this->remoteInfo['description']; 318 return ''; 319 } 320 321 /** 322 * Get the URL of the extension, usually a page on dokuwiki.org 323 * 324 * @return string The URL 325 */ 326 public function getURL() 327 { 328 if (!empty($this->localInfo['url'])) return $this->localInfo['url']; 329 return 'https://www.dokuwiki.org/' . 330 ($this->isTemplate() ? 'template' : 'plugin') . ':' . $this->getBase(); 331 } 332 333 /** 334 * Get the installed version of the extension 335 * 336 * @return string|bool The version, usually in the form yyyy-mm-dd if there is any 337 */ 338 public function getInstalledVersion() 339 { 340 if (!empty($this->localInfo['date'])) return $this->localInfo['date']; 341 if ($this->isInstalled()) return $this->getLang('unknownversion'); 342 return false; 343 } 344 345 /** 346 * Get the install date of the current version 347 * 348 * @return string|bool The date of the last update or false if not available 349 */ 350 public function getUpdateDate() 351 { 352 if (!empty($this->managerData['updated'])) return $this->managerData['updated']; 353 return $this->getInstallDate(); 354 } 355 356 /** 357 * Get the date of the installation of the plugin 358 * 359 * @return string|bool The date of the installation or false if not available 360 */ 361 public function getInstallDate() 362 { 363 if (!empty($this->managerData['installed'])) return $this->managerData['installed']; 364 return false; 365 } 366 367 /** 368 * Get the names of the dependencies of this extension 369 * 370 * @return array The base names of the dependencies 371 */ 372 public function getDependencies() 373 { 374 if (!empty($this->remoteInfo['dependencies'])) return $this->remoteInfo['dependencies']; 375 return []; 376 } 377 378 /** 379 * Get the names of the missing dependencies 380 * 381 * @return array The base names of the missing dependencies 382 */ 383 public function getMissingDependencies() 384 { 385 /* @var PluginController $plugin_controller */ 386 global $plugin_controller; 387 $dependencies = $this->getDependencies(); 388 $missing_dependencies = []; 389 foreach ($dependencies as $dependency) { 390 if (!$plugin_controller->isEnabled($dependency)) { 391 $missing_dependencies[] = $dependency; 392 } 393 } 394 return $missing_dependencies; 395 } 396 397 /** 398 * Get the names of all conflicting extensions 399 * 400 * @return array The names of the conflicting extensions 401 */ 402 public function getConflicts() 403 { 404 if (!empty($this->remoteInfo['conflicts'])) return $this->remoteInfo['conflicts']; 405 return []; 406 } 407 408 /** 409 * Get the names of similar extensions 410 * 411 * @return array The names of similar extensions 412 */ 413 public function getSimilarExtensions() 414 { 415 if (!empty($this->remoteInfo['similar'])) return $this->remoteInfo['similar']; 416 return []; 417 } 418 419 /** 420 * Get the names of the tags of the extension 421 * 422 * @return array The names of the tags of the extension 423 */ 424 public function getTags() 425 { 426 if (!empty($this->remoteInfo['tags'])) return $this->remoteInfo['tags']; 427 return []; 428 } 429 430 /** 431 * Get the popularity information as floating point number [0,1] 432 * 433 * @return float|bool The popularity information or false if it isn't available 434 */ 435 public function getPopularity() 436 { 437 if (!empty($this->remoteInfo['popularity'])) return $this->remoteInfo['popularity']; 438 return false; 439 } 440 441 /** 442 * Get the text of the update message if there is any 443 * 444 * @return string|bool The update message if there is any, false otherwise 445 */ 446 public function getUpdateMessage() 447 { 448 if (!empty($this->remoteInfo['updatemessage'])) return $this->remoteInfo['updatemessage']; 449 return false; 450 } 451 452 /** 453 * Get the text of the security warning if there is any 454 * 455 * @return string|bool The security warning if there is any, false otherwise 456 */ 457 public function getSecurityWarning() 458 { 459 if (!empty($this->remoteInfo['securitywarning'])) return $this->remoteInfo['securitywarning']; 460 return false; 461 } 462 463 /** 464 * Get the text of the security issue if there is any 465 * 466 * @return string|bool The security issue if there is any, false otherwise 467 */ 468 public function getSecurityIssue() 469 { 470 if (!empty($this->remoteInfo['securityissue'])) return $this->remoteInfo['securityissue']; 471 return false; 472 } 473 474 /** 475 * Get the URL of the screenshot of the extension if there is any 476 * 477 * @return string|bool The screenshot URL if there is any, false otherwise 478 */ 479 public function getScreenshotURL() 480 { 481 if (!empty($this->remoteInfo['screenshoturl'])) return $this->remoteInfo['screenshoturl']; 482 return false; 483 } 484 485 /** 486 * Get the URL of the thumbnail of the extension if there is any 487 * 488 * @return string|bool The thumbnail URL if there is any, false otherwise 489 */ 490 public function getThumbnailURL() 491 { 492 if (!empty($this->remoteInfo['thumbnailurl'])) return $this->remoteInfo['thumbnailurl']; 493 return false; 494 } 495 /** 496 * Get the last used download URL of the extension if there is any 497 * 498 * @return string|bool The previously used download URL, false if the extension has been installed manually 499 */ 500 public function getLastDownloadURL() 501 { 502 if (!empty($this->managerData['downloadurl'])) return $this->managerData['downloadurl']; 503 return false; 504 } 505 506 /** 507 * Get the download URL of the extension if there is any 508 * 509 * @return string|bool The download URL if there is any, false otherwise 510 */ 511 public function getDownloadURL() 512 { 513 if (!empty($this->remoteInfo['downloadurl'])) return $this->remoteInfo['downloadurl']; 514 return false; 515 } 516 517 /** 518 * If the download URL has changed since the last download 519 * 520 * @return bool If the download URL has changed 521 */ 522 public function hasDownloadURLChanged() 523 { 524 $lasturl = $this->getLastDownloadURL(); 525 $currenturl = $this->getDownloadURL(); 526 return ($lasturl && $currenturl && $lasturl != $currenturl); 527 } 528 529 /** 530 * Get the bug tracker URL of the extension if there is any 531 * 532 * @return string|bool The bug tracker URL if there is any, false otherwise 533 */ 534 public function getBugtrackerURL() 535 { 536 if (!empty($this->remoteInfo['bugtracker'])) return $this->remoteInfo['bugtracker']; 537 return false; 538 } 539 540 /** 541 * Get the URL of the source repository if there is any 542 * 543 * @return string|bool The URL of the source repository if there is any, false otherwise 544 */ 545 public function getSourcerepoURL() 546 { 547 if (!empty($this->remoteInfo['sourcerepo'])) return $this->remoteInfo['sourcerepo']; 548 return false; 549 } 550 551 /** 552 * Get the donation URL of the extension if there is any 553 * 554 * @return string|bool The donation URL if there is any, false otherwise 555 */ 556 public function getDonationURL() 557 { 558 if (!empty($this->remoteInfo['donationurl'])) return $this->remoteInfo['donationurl']; 559 return false; 560 } 561 562 /** 563 * Get the extension type(s) 564 * 565 * @return array The type(s) as array of strings 566 */ 567 public function getTypes() 568 { 569 if (!empty($this->remoteInfo['types'])) return $this->remoteInfo['types']; 570 if ($this->isTemplate()) return [32 => 'template']; 571 return []; 572 } 573 574 /** 575 * Get a list of all DokuWiki versions this extension is compatible with 576 * 577 * @return array The versions in the form yyyy-mm-dd => ('label' => label, 'implicit' => implicit) 578 */ 579 public function getCompatibleVersions() 580 { 581 if (!empty($this->remoteInfo['compatible'])) return $this->remoteInfo['compatible']; 582 return []; 583 } 584 585 /** 586 * Get the date of the last available update 587 * 588 * @return string|bool The last available update in the form yyyy-mm-dd if there is any, false otherwise 589 */ 590 public function getLastUpdate() 591 { 592 if (!empty($this->remoteInfo['lastupdate'])) return $this->remoteInfo['lastupdate']; 593 return false; 594 } 595 596 /** 597 * Get the base path of the extension 598 * 599 * @return string The base path of the extension 600 */ 601 public function getInstallDir() 602 { 603 if ($this->isTemplate()) { 604 return $this->tpllib . $this->base; 605 } else { 606 return DOKU_PLUGIN . $this->base; 607 } 608 } 609 610 /** 611 * The type of extension installation 612 * 613 * @return string One of "none", "manual", "git" or "automatic" 614 */ 615 public function getInstallType() 616 { 617 if (!$this->isInstalled()) return 'none'; 618 if (!empty($this->managerData)) return 'automatic'; 619 if (is_dir($this->getInstallDir() . '/.git')) return 'git'; 620 return 'manual'; 621 } 622 623 /** 624 * If the extension can probably be installed/updated or uninstalled 625 * 626 * @return bool|string True or error string 627 */ 628 public function canModify() 629 { 630 if ($this->isInstalled()) { 631 if (!is_writable($this->getInstallDir())) { 632 return 'noperms'; 633 } 634 } 635 636 if ($this->isTemplate() && !is_writable($this->tpllib)) { 637 return 'notplperms'; 638 } elseif (!is_writable(DOKU_PLUGIN)) { 639 return 'nopluginperms'; 640 } 641 return true; 642 } 643 644 /** 645 * Install an extension from a user upload 646 * 647 * @param string $field name of the upload file 648 * @param boolean $overwrite overwrite folder if the extension name is the same 649 * @throws Exception when something goes wrong 650 * @return array The list of installed extensions 651 */ 652 public function installFromUpload($field, $overwrite = true) 653 { 654 if ($_FILES[$field]['error']) { 655 throw new Exception($this->getLang('msg_upload_failed') . ' (' . $_FILES[$field]['error'] . ')'); 656 } 657 658 $tmp = $this->mkTmpDir(); 659 if (!$tmp) throw new Exception($this->getLang('error_dircreate')); 660 661 // filename may contain the plugin name for old style plugins... 662 $basename = basename($_FILES[$field]['name']); 663 $basename = preg_replace('/\.(tar\.gz|tar\.bz|tar\.bz2|tar|tgz|tbz|zip)$/', '', $basename); 664 $basename = preg_replace('/[\W]+/', '', $basename); 665 666 if (!move_uploaded_file($_FILES[$field]['tmp_name'], "$tmp/upload.archive")) { 667 throw new Exception($this->getLang('msg_upload_failed')); 668 } 669 $installed = $this->installArchive("$tmp/upload.archive", $overwrite, $basename); 670 $this->updateManagerData('', $installed); 671 $this->removeDeletedfiles($installed); 672 $this->purgeCache(); 673 return $installed; 674 } 675 676 /** 677 * Install an extension from a remote URL 678 * 679 * @param string $url 680 * @param boolean $overwrite overwrite folder if the extension name is the same 681 * @throws Exception when something goes wrong 682 * @return array The list of installed extensions 683 */ 684 public function installFromURL($url, $overwrite = true) 685 { 686 $path = $this->download($url); 687 $installed = $this->installArchive($path, $overwrite); 688 $this->updateManagerData($url, $installed); 689 $this->removeDeletedfiles($installed); 690 $this->purgeCache(); 691 return $installed; 692 } 693 694 /** 695 * Install or update the extension 696 * 697 * @throws \Exception when something goes wrong 698 * @return array The list of installed extensions 699 */ 700 public function installOrUpdate() 701 { 702 $url = $this->getDownloadURL(); 703 $path = $this->download($url); 704 $installed = $this->installArchive($path, $this->isInstalled(), $this->getBase()); 705 $this->updateManagerData($url, $installed); 706 707 // refresh extension information 708 if (!isset($installed[$this->getID()])) { 709 throw new Exception('Error, the requested extension hasn\'t been installed or updated'); 710 } 711 $this->removeDeletedfiles($installed); 712 $this->setExtension($this->getID()); 713 $this->purgeCache(); 714 return $installed; 715 } 716 717 /** 718 * Uninstall the extension 719 * 720 * @return bool If the plugin was sucessfully uninstalled 721 */ 722 public function uninstall() 723 { 724 $this->purgeCache(); 725 return io_rmdir($this->getInstallDir(), true); 726 } 727 728 /** 729 * Enable the extension 730 * 731 * @return bool|string True or an error message 732 */ 733 public function enable() 734 { 735 if ($this->isTemplate()) return $this->getLang('notimplemented'); 736 if (!$this->isInstalled()) return $this->getLang('notinstalled'); 737 if ($this->isEnabled()) return $this->getLang('alreadyenabled'); 738 739 /* @var PluginController $plugin_controller */ 740 global $plugin_controller; 741 if ($plugin_controller->enable($this->base)) { 742 $this->purgeCache(); 743 return true; 744 } else { 745 return $this->getLang('pluginlistsaveerror'); 746 } 747 } 748 749 /** 750 * Disable the extension 751 * 752 * @return bool|string True or an error message 753 */ 754 public function disable() 755 { 756 if ($this->isTemplate()) return $this->getLang('notimplemented'); 757 758 /* @var PluginController $plugin_controller */ 759 global $plugin_controller; 760 if (!$this->isInstalled()) return $this->getLang('notinstalled'); 761 if (!$this->isEnabled()) return $this->getLang('alreadydisabled'); 762 if ($plugin_controller->disable($this->base)) { 763 $this->purgeCache(); 764 return true; 765 } else { 766 return $this->getLang('pluginlistsaveerror'); 767 } 768 } 769 770 /** 771 * Purge the cache by touching the main configuration file 772 */ 773 protected function purgeCache() 774 { 775 global $config_cascade; 776 777 // expire dokuwiki caches 778 // touching local.php expires wiki page, JS and CSS caches 779 @touch(reset($config_cascade['main']['local'])); 780 } 781 782 /** 783 * Read local extension data either from info.txt or getInfo() 784 */ 785 protected function readLocalData() 786 { 787 if ($this->isTemplate()) { 788 $infopath = $this->getInstallDir() . '/template.info.txt'; 789 } else { 790 $infopath = $this->getInstallDir() . '/plugin.info.txt'; 791 } 792 793 if (is_readable($infopath)) { 794 $this->localInfo = confToHash($infopath); 795 } elseif (!$this->isTemplate() && $this->isEnabled()) { 796 $path = $this->getInstallDir() . '/'; 797 $plugin = null; 798 799 foreach (PluginController::PLUGIN_TYPES as $type) { 800 if (file_exists($path . $type . '.php')) { 801 $plugin = plugin_load($type, $this->base); 802 if ($plugin instanceof PluginInterface) break; 803 } 804 805 if ($dh = @opendir($path . $type . '/')) { 806 while (false !== ($cp = readdir($dh))) { 807 if ($cp == '.' || $cp == '..' || !str_ends_with(strtolower($cp), '.php')) continue; 808 809 $plugin = plugin_load($type, $this->base . '_' . substr($cp, 0, -4)); 810 if ($plugin instanceof PluginInterface) break; 811 } 812 if ($plugin instanceof PluginInterface) break; 813 closedir($dh); 814 } 815 } 816 817 if ($plugin instanceof PluginInterface) { 818 $this->localInfo = $plugin->getInfo(); 819 } 820 } 821 } 822 823 /** 824 * Save the given URL and current datetime in the manager.dat file of all installed extensions 825 * 826 * @param string $url Where the extension was downloaded from. (empty for manual installs via upload) 827 * @param array $installed Optional list of installed plugins 828 */ 829 protected function updateManagerData($url = '', $installed = null) 830 { 831 $origID = $this->getID(); 832 833 if (is_null($installed)) { 834 $installed = [$origID]; 835 } 836 837 foreach (array_keys($installed) as $ext) { 838 if ($this->getID() != $ext) $this->setExtension($ext); 839 if ($url) { 840 $this->managerData['downloadurl'] = $url; 841 } elseif (isset($this->managerData['downloadurl'])) { 842 unset($this->managerData['downloadurl']); 843 } 844 if (isset($this->managerData['installed'])) { 845 $this->managerData['updated'] = date('r'); 846 } else { 847 $this->managerData['installed'] = date('r'); 848 } 849 $this->writeManagerData(); 850 } 851 852 if ($this->getID() != $origID) $this->setExtension($origID); 853 } 854 855 /** 856 * Read the manager.dat file 857 */ 858 protected function readManagerData() 859 { 860 $managerpath = $this->getInstallDir() . '/manager.dat'; 861 if (is_readable($managerpath)) { 862 $file = @file($managerpath); 863 if (!empty($file)) { 864 foreach ($file as $line) { 865 [$key, $value] = sexplode('=', trim($line, DOKU_LF), 2, ''); 866 $key = trim($key); 867 $value = trim($value); 868 // backwards compatible with old plugin manager 869 if ($key == 'url') $key = 'downloadurl'; 870 $this->managerData[$key] = $value; 871 } 872 } 873 } 874 } 875 876 /** 877 * Write the manager.data file 878 */ 879 protected function writeManagerData() 880 { 881 $managerpath = $this->getInstallDir() . '/manager.dat'; 882 $data = ''; 883 foreach ($this->managerData as $k => $v) { 884 $data .= $k . '=' . $v . DOKU_LF; 885 } 886 io_saveFile($managerpath, $data); 887 } 888 889 /** 890 * Returns a temporary directory 891 * 892 * The directory is registered for cleanup when the class is destroyed 893 * 894 * @return false|string 895 */ 896 protected function mkTmpDir() 897 { 898 $dir = io_mktmpdir(); 899 if (!$dir) return false; 900 $this->temporary[] = $dir; 901 return $dir; 902 } 903 904 /** 905 * downloads a file from the net and saves it 906 * 907 * - $file is the directory where the file should be saved 908 * - if successful will return the name used for the saved file, false otherwise 909 * 910 * @author Andreas Gohr <andi@splitbrain.org> 911 * @author Chris Smith <chris@jalakai.co.uk> 912 * 913 * @param string $url url to download 914 * @param string $file path to file or directory where to save 915 * @param string $defaultName fallback for name of download 916 * @return bool|string if failed false, otherwise true or the name of the file in the given dir 917 */ 918 protected function downloadToFile($url, $file, $defaultName = '') 919 { 920 global $conf; 921 $http = new DokuHTTPClient(); 922 $http->max_bodysize = 0; 923 $http->timeout = 25; //max. 25 sec 924 $http->keep_alive = false; // we do single ops here, no need for keep-alive 925 $http->agent = 'DokuWiki HTTP Client (Extension Manager)'; 926 927 $data = $http->get($url); 928 if ($data === false) return false; 929 930 $name = ''; 931 if (isset($http->resp_headers['content-disposition'])) { 932 $content_disposition = $http->resp_headers['content-disposition']; 933 $match = []; 934 if ( 935 is_string($content_disposition) && 936 preg_match('/attachment;\s*filename\s*=\s*"([^"]*)"/i', $content_disposition, $match) 937 ) { 938 $name = PhpString::basename($match[1]); 939 } 940 } 941 942 if (!$name) { 943 if (!$defaultName) return false; 944 $name = $defaultName; 945 } 946 947 $file .= $name; 948 949 $fileexists = file_exists($file); 950 $fp = @fopen($file, "w"); 951 if (!$fp) return false; 952 fwrite($fp, $data); 953 fclose($fp); 954 if (!$fileexists && $conf['fperm']) chmod($file, $conf['fperm']); 955 return $name; 956 } 957 958 /** 959 * Download an archive to a protected path 960 * 961 * @param string $url The url to get the archive from 962 * @throws Exception when something goes wrong 963 * @return string The path where the archive was saved 964 */ 965 public function download($url) 966 { 967 // check the url 968 if (!preg_match('/https?:\/\//i', $url)) { 969 throw new Exception($this->getLang('error_badurl')); 970 } 971 972 // try to get the file from the path (used as plugin name fallback) 973 $file = parse_url($url, PHP_URL_PATH); 974 if (is_null($file)) { 975 $file = md5($url); 976 } else { 977 $file = PhpString::basename($file); 978 } 979 980 // create tmp directory for download 981 if (!($tmp = $this->mkTmpDir())) { 982 throw new Exception($this->getLang('error_dircreate')); 983 } 984 985 // download 986 if (!$file = $this->downloadToFile($url, $tmp . '/', $file)) { 987 io_rmdir($tmp, true); 988 throw new Exception(sprintf( 989 $this->getLang('error_download'), 990 '<bdi>' . hsc($url) . '</bdi>' 991 )); 992 } 993 994 return $tmp . '/' . $file; 995 } 996 997 /** 998 * @param string $file The path to the archive that shall be installed 999 * @param bool $overwrite If an already installed plugin should be overwritten 1000 * @param string $base The basename of the plugin if it's known 1001 * @throws Exception when something went wrong 1002 * @return array list of installed extensions 1003 */ 1004 public function installArchive($file, $overwrite = false, $base = '') 1005 { 1006 $installed_extensions = []; 1007 1008 // create tmp directory for decompression 1009 if (!($tmp = $this->mkTmpDir())) { 1010 throw new Exception($this->getLang('error_dircreate')); 1011 } 1012 1013 // add default base folder if specified to handle case where zip doesn't contain this 1014 if ($base && !@mkdir($tmp . '/' . $base)) { 1015 throw new Exception($this->getLang('error_dircreate')); 1016 } 1017 1018 // decompress 1019 $this->decompress($file, "$tmp/" . $base); 1020 1021 // search $tmp/$base for the folder(s) that has been created 1022 // move the folder(s) to lib/.. 1023 $result = ['old' => [], 'new' => []]; 1024 $default = ($this->isTemplate() ? 'template' : 'plugin'); 1025 if (!$this->findFolders($result, $tmp . '/' . $base, $default)) { 1026 throw new Exception($this->getLang('error_findfolder')); 1027 } 1028 1029 // choose correct result array 1030 if (count($result['new'])) { 1031 $install = $result['new']; 1032 } else { 1033 $install = $result['old']; 1034 } 1035 1036 if (!count($install)) { 1037 throw new Exception($this->getLang('error_findfolder')); 1038 } 1039 1040 // now install all found items 1041 foreach ($install as $item) { 1042 // where to install? 1043 if ($item['type'] == 'template') { 1044 $target_base_dir = $this->tpllib; 1045 } else { 1046 $target_base_dir = DOKU_PLUGIN; 1047 } 1048 1049 if (!empty($item['base'])) { 1050 // use base set in info.txt 1051 } elseif ($base && count($install) == 1) { 1052 $item['base'] = $base; 1053 } else { 1054 // default - use directory as found in zip 1055 // plugins from github/master without *.info.txt will install in wrong folder 1056 // but using $info->id will make 'code3' fail (which should install in lib/code/..) 1057 $item['base'] = basename($item['tmp']); 1058 } 1059 1060 // check to make sure we aren't overwriting anything 1061 $target = $target_base_dir . $item['base']; 1062 if (!$overwrite && file_exists($target)) { 1063 // this info message is not being exposed via exception, 1064 // so that it's not interrupting the installation 1065 msg(sprintf($this->getLang('msg_nooverwrite'), $item['base'])); 1066 continue; 1067 } 1068 1069 $action = file_exists($target) ? 'update' : 'install'; 1070 1071 // copy action 1072 if ($this->dircopy($item['tmp'], $target)) { 1073 // return info 1074 $id = $item['base']; 1075 if ($item['type'] == 'template') { 1076 $id = 'template:' . $id; 1077 } 1078 $installed_extensions[$id] = [ 1079 'base' => $item['base'], 1080 'type' => $item['type'], 1081 'action' => $action 1082 ]; 1083 } else { 1084 throw new Exception(sprintf( 1085 $this->getLang('error_copy') . DOKU_LF, 1086 '<bdi>' . $item['base'] . '</bdi>' 1087 )); 1088 } 1089 } 1090 1091 // cleanup 1092 if ($tmp) io_rmdir($tmp, true); 1093 if (function_exists('opcache_reset')) { 1094 opcache_reset(); 1095 } 1096 1097 return $installed_extensions; 1098 } 1099 1100 /** 1101 * Find out what was in the extracted directory 1102 * 1103 * Correct folders are searched recursively using the "*.info.txt" configs 1104 * as indicator for a root folder. When such a file is found, it's base 1105 * setting is used (when set). All folders found by this method are stored 1106 * in the 'new' key of the $result array. 1107 * 1108 * For backwards compatibility all found top level folders are stored as 1109 * in the 'old' key of the $result array. 1110 * 1111 * When no items are found in 'new' the copy mechanism should fall back 1112 * the 'old' list. 1113 * 1114 * @author Andreas Gohr <andi@splitbrain.org> 1115 * @param array $result - results are stored here 1116 * @param string $directory - the temp directory where the package was unpacked to 1117 * @param string $default_type - type used if no info.txt available 1118 * @param string $subdir - a subdirectory. do not set. used by recursion 1119 * @return bool - false on error 1120 */ 1121 protected function findFolders(&$result, $directory, $default_type = 'plugin', $subdir = '') 1122 { 1123 $this_dir = "$directory$subdir"; 1124 $dh = @opendir($this_dir); 1125 if (!$dh) return false; 1126 1127 $found_dirs = []; 1128 $found_files = 0; 1129 $found_template_parts = 0; 1130 while (false !== ($f = readdir($dh))) { 1131 if ($f == '.' || $f == '..') continue; 1132 1133 if (is_dir("$this_dir/$f")) { 1134 $found_dirs[] = "$subdir/$f"; 1135 } else { 1136 // it's a file -> check for config 1137 $found_files++; 1138 switch ($f) { 1139 case 'plugin.info.txt': 1140 case 'template.info.txt': 1141 // we have found a clear marker, save and return 1142 $info = []; 1143 $type = explode('.', $f, 2); 1144 $info['type'] = $type[0]; 1145 $info['tmp'] = $this_dir; 1146 $conf = confToHash("$this_dir/$f"); 1147 $info['base'] = basename($conf['base']); 1148 $result['new'][] = $info; 1149 return true; 1150 1151 case 'main.php': 1152 case 'details.php': 1153 case 'mediamanager.php': 1154 case 'style.ini': 1155 $found_template_parts++; 1156 break; 1157 } 1158 } 1159 } 1160 closedir($dh); 1161 1162 // files where found but no info.txt - use old method 1163 if ($found_files) { 1164 $info = []; 1165 $info['tmp'] = $this_dir; 1166 // does this look like a template or should we use the default type? 1167 if ($found_template_parts >= 2) { 1168 $info['type'] = 'template'; 1169 } else { 1170 $info['type'] = $default_type; 1171 } 1172 1173 $result['old'][] = $info; 1174 return true; 1175 } 1176 1177 // we have no files yet -> recurse 1178 foreach ($found_dirs as $found_dir) { 1179 $this->findFolders($result, $directory, $default_type, "$found_dir"); 1180 } 1181 return true; 1182 } 1183 1184 /** 1185 * Decompress a given file to the given target directory 1186 * 1187 * Determines the compression type from the file extension 1188 * 1189 * @param string $file archive to extract 1190 * @param string $target directory to extract to 1191 * @throws Exception 1192 * @return bool 1193 */ 1194 private function decompress($file, $target) 1195 { 1196 // decompression library doesn't like target folders ending in "/" 1197 if (str_ends_with($target, '/')) $target = substr($target, 0, -1); 1198 1199 $ext = $this->guessArchiveType($file); 1200 if (in_array($ext, ['tar', 'bz', 'gz'])) { 1201 try { 1202 $tar = new Tar(); 1203 $tar->open($file); 1204 $tar->extract($target); 1205 } catch (ArchiveIOException $e) { 1206 throw new Exception($this->getLang('error_decompress') . ' ' . $e->getMessage(), $e->getCode(), $e); 1207 } 1208 1209 return true; 1210 } elseif ($ext == 'zip') { 1211 try { 1212 $zip = new Zip(); 1213 $zip->open($file); 1214 $zip->extract($target); 1215 } catch (ArchiveIOException $e) { 1216 throw new Exception($this->getLang('error_decompress') . ' ' . $e->getMessage(), $e->getCode(), $e); 1217 } 1218 1219 return true; 1220 } 1221 1222 // the only case when we don't get one of the recognized archive types is 1223 // when the archive file can't be read 1224 throw new Exception($this->getLang('error_decompress') . ' Couldn\'t read archive file'); 1225 } 1226 1227 /** 1228 * Determine the archive type of the given file 1229 * 1230 * Reads the first magic bytes of the given file for content type guessing, 1231 * if neither bz, gz or zip are recognized, tar is assumed. 1232 * 1233 * @author Andreas Gohr <andi@splitbrain.org> 1234 * @param string $file The file to analyze 1235 * @return string|false false if the file can't be read, otherwise an "extension" 1236 */ 1237 private function guessArchiveType($file) 1238 { 1239 $fh = fopen($file, 'rb'); 1240 if (!$fh) return false; 1241 $magic = fread($fh, 5); 1242 fclose($fh); 1243 1244 if (strpos($magic, "\x42\x5a") === 0) return 'bz'; 1245 if (strpos($magic, "\x1f\x8b") === 0) return 'gz'; 1246 if (strpos($magic, "\x50\x4b\x03\x04") === 0) return 'zip'; 1247 return 'tar'; 1248 } 1249 1250 /** 1251 * Copy with recursive sub-directory support 1252 * 1253 * @param string $src filename path to file 1254 * @param string $dst filename path to file 1255 * @return bool|int|string 1256 */ 1257 private function dircopy($src, $dst) 1258 { 1259 global $conf; 1260 1261 if (is_dir($src)) { 1262 if (!$dh = @opendir($src)) return false; 1263 1264 if ($ok = io_mkdir_p($dst)) { 1265 while ($ok && (false !== ($f = readdir($dh)))) { 1266 if ($f == '..' || $f == '.') continue; 1267 $ok = $this->dircopy("$src/$f", "$dst/$f"); 1268 } 1269 } 1270 1271 closedir($dh); 1272 return $ok; 1273 } else { 1274 $existed = file_exists($dst); 1275 1276 if (!@copy($src, $dst)) return false; 1277 if (!$existed && $conf['fperm']) chmod($dst, $conf['fperm']); 1278 @touch($dst, filemtime($src)); 1279 } 1280 1281 return true; 1282 } 1283 1284 /** 1285 * Delete outdated files from updated plugins 1286 * 1287 * @param array $installed 1288 */ 1289 private function removeDeletedfiles($installed) 1290 { 1291 foreach ($installed as $extension) { 1292 // only on update 1293 if ($extension['action'] == 'install') continue; 1294 1295 // get definition file 1296 if ($extension['type'] == 'template') { 1297 $extensiondir = $this->tpllib; 1298 } else { 1299 $extensiondir = DOKU_PLUGIN; 1300 } 1301 $extensiondir = $extensiondir . $extension['base'] . '/'; 1302 $definitionfile = $extensiondir . 'deleted.files'; 1303 if (!file_exists($definitionfile)) continue; 1304 1305 // delete the old files 1306 $list = file($definitionfile); 1307 1308 foreach ($list as $line) { 1309 $line = trim(preg_replace('/#.*$/', '', $line)); 1310 if (!$line) continue; 1311 $file = $extensiondir . $line; 1312 if (!file_exists($file)) continue; 1313 1314 io_rmdir($file, true); 1315 } 1316 } 1317 } 1318} 1319 1320// vim:ts=4:sw=4:et: 1321