[ Index ] |
PHP Cross Reference of DokuWiki |
[Summary view] [Print] [Text view]
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 10 use dokuwiki\Extension\Plugin; 11 use dokuwiki\Extension\PluginInterface; 12 use dokuwiki\Utf8\PhpString; 13 use splitbrain\PHPArchive\Tar; 14 use splitbrain\PHPArchive\ArchiveIOException; 15 use splitbrain\PHPArchive\Zip; 16 use dokuwiki\HTTP\DokuHTTPClient; 17 use dokuwiki\Extension\PluginController; 18 19 /** 20 * Class helper_plugin_extension_extension represents a single extension (plugin or template) 21 */ 22 class 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 /** 443 * Get the text of the security warning if there is any 444 * 445 * @return string|bool The security warning if there is any, false otherwise 446 */ 447 public function getSecurityWarning() 448 { 449 if (!empty($this->remoteInfo['securitywarning'])) return $this->remoteInfo['securitywarning']; 450 return false; 451 } 452 453 /** 454 * Get the text of the security issue if there is any 455 * 456 * @return string|bool The security issue if there is any, false otherwise 457 */ 458 public function getSecurityIssue() 459 { 460 if (!empty($this->remoteInfo['securityissue'])) return $this->remoteInfo['securityissue']; 461 return false; 462 } 463 464 /** 465 * Get the URL of the screenshot of the extension if there is any 466 * 467 * @return string|bool The screenshot URL if there is any, false otherwise 468 */ 469 public function getScreenshotURL() 470 { 471 if (!empty($this->remoteInfo['screenshoturl'])) return $this->remoteInfo['screenshoturl']; 472 return false; 473 } 474 475 /** 476 * Get the URL of the thumbnail of the extension if there is any 477 * 478 * @return string|bool The thumbnail URL if there is any, false otherwise 479 */ 480 public function getThumbnailURL() 481 { 482 if (!empty($this->remoteInfo['thumbnailurl'])) return $this->remoteInfo['thumbnailurl']; 483 return false; 484 } 485 /** 486 * Get the last used download URL of the extension if there is any 487 * 488 * @return string|bool The previously used download URL, false if the extension has been installed manually 489 */ 490 public function getLastDownloadURL() 491 { 492 if (!empty($this->managerData['downloadurl'])) return $this->managerData['downloadurl']; 493 return false; 494 } 495 496 /** 497 * Get the download URL of the extension if there is any 498 * 499 * @return string|bool The download URL if there is any, false otherwise 500 */ 501 public function getDownloadURL() 502 { 503 if (!empty($this->remoteInfo['downloadurl'])) return $this->remoteInfo['downloadurl']; 504 return false; 505 } 506 507 /** 508 * If the download URL has changed since the last download 509 * 510 * @return bool If the download URL has changed 511 */ 512 public function hasDownloadURLChanged() 513 { 514 $lasturl = $this->getLastDownloadURL(); 515 $currenturl = $this->getDownloadURL(); 516 return ($lasturl && $currenturl && $lasturl != $currenturl); 517 } 518 519 /** 520 * Get the bug tracker URL of the extension if there is any 521 * 522 * @return string|bool The bug tracker URL if there is any, false otherwise 523 */ 524 public function getBugtrackerURL() 525 { 526 if (!empty($this->remoteInfo['bugtracker'])) return $this->remoteInfo['bugtracker']; 527 return false; 528 } 529 530 /** 531 * Get the URL of the source repository if there is any 532 * 533 * @return string|bool The URL of the source repository if there is any, false otherwise 534 */ 535 public function getSourcerepoURL() 536 { 537 if (!empty($this->remoteInfo['sourcerepo'])) return $this->remoteInfo['sourcerepo']; 538 return false; 539 } 540 541 /** 542 * Get the donation URL of the extension if there is any 543 * 544 * @return string|bool The donation URL if there is any, false otherwise 545 */ 546 public function getDonationURL() 547 { 548 if (!empty($this->remoteInfo['donationurl'])) return $this->remoteInfo['donationurl']; 549 return false; 550 } 551 552 /** 553 * Get the extension type(s) 554 * 555 * @return array The type(s) as array of strings 556 */ 557 public function getTypes() 558 { 559 if (!empty($this->remoteInfo['types'])) return $this->remoteInfo['types']; 560 if ($this->isTemplate()) return [32 => 'template']; 561 return []; 562 } 563 564 /** 565 * Get a list of all DokuWiki versions this extension is compatible with 566 * 567 * @return array The versions in the form yyyy-mm-dd => ('label' => label, 'implicit' => implicit) 568 */ 569 public function getCompatibleVersions() 570 { 571 if (!empty($this->remoteInfo['compatible'])) return $this->remoteInfo['compatible']; 572 return []; 573 } 574 575 /** 576 * Get the date of the last available update 577 * 578 * @return string|bool The last available update in the form yyyy-mm-dd if there is any, false otherwise 579 */ 580 public function getLastUpdate() 581 { 582 if (!empty($this->remoteInfo['lastupdate'])) return $this->remoteInfo['lastupdate']; 583 return false; 584 } 585 586 /** 587 * Get the base path of the extension 588 * 589 * @return string The base path of the extension 590 */ 591 public function getInstallDir() 592 { 593 if ($this->isTemplate()) { 594 return $this->tpllib . $this->base; 595 } else { 596 return DOKU_PLUGIN . $this->base; 597 } 598 } 599 600 /** 601 * The type of extension installation 602 * 603 * @return string One of "none", "manual", "git" or "automatic" 604 */ 605 public function getInstallType() 606 { 607 if (!$this->isInstalled()) return 'none'; 608 if (!empty($this->managerData)) return 'automatic'; 609 if (is_dir($this->getInstallDir() . '/.git')) return 'git'; 610 return 'manual'; 611 } 612 613 /** 614 * If the extension can probably be installed/updated or uninstalled 615 * 616 * @return bool|string True or error string 617 */ 618 public function canModify() 619 { 620 if ($this->isInstalled()) { 621 if (!is_writable($this->getInstallDir())) { 622 return 'noperms'; 623 } 624 } 625 626 if ($this->isTemplate() && !is_writable($this->tpllib)) { 627 return 'notplperms'; 628 } elseif (!is_writable(DOKU_PLUGIN)) { 629 return 'nopluginperms'; 630 } 631 return true; 632 } 633 634 /** 635 * Install an extension from a user upload 636 * 637 * @param string $field name of the upload file 638 * @param boolean $overwrite overwrite folder if the extension name is the same 639 * @throws Exception when something goes wrong 640 * @return array The list of installed extensions 641 */ 642 public function installFromUpload($field, $overwrite = true) 643 { 644 if ($_FILES[$field]['error']) { 645 throw new Exception($this->getLang('msg_upload_failed') . ' (' . $_FILES[$field]['error'] . ')'); 646 } 647 648 $tmp = $this->mkTmpDir(); 649 if (!$tmp) throw new Exception($this->getLang('error_dircreate')); 650 651 // filename may contain the plugin name for old style plugins... 652 $basename = basename($_FILES[$field]['name']); 653 $basename = preg_replace('/\.(tar\.gz|tar\.bz|tar\.bz2|tar|tgz|tbz|zip)$/', '', $basename); 654 $basename = preg_replace('/[\W]+/', '', $basename); 655 656 if (!move_uploaded_file($_FILES[$field]['tmp_name'], "$tmp/upload.archive")) { 657 throw new Exception($this->getLang('msg_upload_failed')); 658 } 659 $installed = $this->installArchive("$tmp/upload.archive", $overwrite, $basename); 660 $this->updateManagerData('', $installed); 661 $this->removeDeletedfiles($installed); 662 $this->purgeCache(); 663 return $installed; 664 } 665 666 /** 667 * Install an extension from a remote URL 668 * 669 * @param string $url 670 * @param boolean $overwrite overwrite folder if the extension name is the same 671 * @throws Exception when something goes wrong 672 * @return array The list of installed extensions 673 */ 674 public function installFromURL($url, $overwrite = true) 675 { 676 $path = $this->download($url); 677 $installed = $this->installArchive($path, $overwrite); 678 $this->updateManagerData($url, $installed); 679 $this->removeDeletedfiles($installed); 680 $this->purgeCache(); 681 return $installed; 682 } 683 684 /** 685 * Install or update the extension 686 * 687 * @throws \Exception when something goes wrong 688 * @return array The list of installed extensions 689 */ 690 public function installOrUpdate() 691 { 692 $url = $this->getDownloadURL(); 693 $path = $this->download($url); 694 $installed = $this->installArchive($path, $this->isInstalled(), $this->getBase()); 695 $this->updateManagerData($url, $installed); 696 697 // refresh extension information 698 if (!isset($installed[$this->getID()])) { 699 throw new Exception('Error, the requested extension hasn\'t been installed or updated'); 700 } 701 $this->removeDeletedfiles($installed); 702 $this->setExtension($this->getID()); 703 $this->purgeCache(); 704 return $installed; 705 } 706 707 /** 708 * Uninstall the extension 709 * 710 * @return bool If the plugin was sucessfully uninstalled 711 */ 712 public function uninstall() 713 { 714 $this->purgeCache(); 715 return io_rmdir($this->getInstallDir(), true); 716 } 717 718 /** 719 * Enable the extension 720 * 721 * @return bool|string True or an error message 722 */ 723 public function enable() 724 { 725 if ($this->isTemplate()) return $this->getLang('notimplemented'); 726 if (!$this->isInstalled()) return $this->getLang('notinstalled'); 727 if ($this->isEnabled()) return $this->getLang('alreadyenabled'); 728 729 /* @var PluginController $plugin_controller */ 730 global $plugin_controller; 731 if ($plugin_controller->enable($this->base)) { 732 $this->purgeCache(); 733 return true; 734 } else { 735 return $this->getLang('pluginlistsaveerror'); 736 } 737 } 738 739 /** 740 * Disable the extension 741 * 742 * @return bool|string True or an error message 743 */ 744 public function disable() 745 { 746 if ($this->isTemplate()) return $this->getLang('notimplemented'); 747 748 /* @var PluginController $plugin_controller */ 749 global $plugin_controller; 750 if (!$this->isInstalled()) return $this->getLang('notinstalled'); 751 if (!$this->isEnabled()) return $this->getLang('alreadydisabled'); 752 if ($plugin_controller->disable($this->base)) { 753 $this->purgeCache(); 754 return true; 755 } else { 756 return $this->getLang('pluginlistsaveerror'); 757 } 758 } 759 760 /** 761 * Purge the cache by touching the main configuration file 762 */ 763 protected function purgeCache() 764 { 765 global $config_cascade; 766 767 // expire dokuwiki caches 768 // touching local.php expires wiki page, JS and CSS caches 769 @touch(reset($config_cascade['main']['local'])); 770 } 771 772 /** 773 * Read local extension data either from info.txt or getInfo() 774 */ 775 protected function readLocalData() 776 { 777 if ($this->isTemplate()) { 778 $infopath = $this->getInstallDir() . '/template.info.txt'; 779 } else { 780 $infopath = $this->getInstallDir() . '/plugin.info.txt'; 781 } 782 783 if (is_readable($infopath)) { 784 $this->localInfo = confToHash($infopath); 785 } elseif (!$this->isTemplate() && $this->isEnabled()) { 786 $path = $this->getInstallDir() . '/'; 787 $plugin = null; 788 789 foreach (PluginController::PLUGIN_TYPES as $type) { 790 if (file_exists($path . $type . '.php')) { 791 $plugin = plugin_load($type, $this->base); 792 if ($plugin instanceof PluginInterface) break; 793 } 794 795 if ($dh = @opendir($path . $type . '/')) { 796 while (false !== ($cp = readdir($dh))) { 797 if ($cp == '.' || $cp == '..' || !str_ends_with(strtolower($cp), '.php')) continue; 798 799 $plugin = plugin_load($type, $this->base . '_' . substr($cp, 0, -4)); 800 if ($plugin instanceof PluginInterface) break; 801 } 802 if ($plugin instanceof PluginInterface) break; 803 closedir($dh); 804 } 805 } 806 807 if ($plugin instanceof PluginInterface) { 808 $this->localInfo = $plugin->getInfo(); 809 } 810 } 811 } 812 813 /** 814 * Save the given URL and current datetime in the manager.dat file of all installed extensions 815 * 816 * @param string $url Where the extension was downloaded from. (empty for manual installs via upload) 817 * @param array $installed Optional list of installed plugins 818 */ 819 protected function updateManagerData($url = '', $installed = null) 820 { 821 $origID = $this->getID(); 822 823 if (is_null($installed)) { 824 $installed = [$origID]; 825 } 826 827 foreach (array_keys($installed) as $ext) { 828 if ($this->getID() != $ext) $this->setExtension($ext); 829 if ($url) { 830 $this->managerData['downloadurl'] = $url; 831 } elseif (isset($this->managerData['downloadurl'])) { 832 unset($this->managerData['downloadurl']); 833 } 834 if (isset($this->managerData['installed'])) { 835 $this->managerData['updated'] = date('r'); 836 } else { 837 $this->managerData['installed'] = date('r'); 838 } 839 $this->writeManagerData(); 840 } 841 842 if ($this->getID() != $origID) $this->setExtension($origID); 843 } 844 845 /** 846 * Read the manager.dat file 847 */ 848 protected function readManagerData() 849 { 850 $managerpath = $this->getInstallDir() . '/manager.dat'; 851 if (is_readable($managerpath)) { 852 $file = @file($managerpath); 853 if (!empty($file)) { 854 foreach ($file as $line) { 855 [$key, $value] = sexplode('=', trim($line, DOKU_LF), 2, ''); 856 $key = trim($key); 857 $value = trim($value); 858 // backwards compatible with old plugin manager 859 if ($key == 'url') $key = 'downloadurl'; 860 $this->managerData[$key] = $value; 861 } 862 } 863 } 864 } 865 866 /** 867 * Write the manager.data file 868 */ 869 protected function writeManagerData() 870 { 871 $managerpath = $this->getInstallDir() . '/manager.dat'; 872 $data = ''; 873 foreach ($this->managerData as $k => $v) { 874 $data .= $k . '=' . $v . DOKU_LF; 875 } 876 io_saveFile($managerpath, $data); 877 } 878 879 /** 880 * Returns a temporary directory 881 * 882 * The directory is registered for cleanup when the class is destroyed 883 * 884 * @return false|string 885 */ 886 protected function mkTmpDir() 887 { 888 $dir = io_mktmpdir(); 889 if (!$dir) return false; 890 $this->temporary[] = $dir; 891 return $dir; 892 } 893 894 /** 895 * downloads a file from the net and saves it 896 * 897 * - $file is the directory where the file should be saved 898 * - if successful will return the name used for the saved file, false otherwise 899 * 900 * @author Andreas Gohr <andi@splitbrain.org> 901 * @author Chris Smith <chris@jalakai.co.uk> 902 * 903 * @param string $url url to download 904 * @param string $file path to file or directory where to save 905 * @param string $defaultName fallback for name of download 906 * @return bool|string if failed false, otherwise true or the name of the file in the given dir 907 */ 908 protected function downloadToFile($url, $file, $defaultName = '') 909 { 910 global $conf; 911 $http = new DokuHTTPClient(); 912 $http->max_bodysize = 0; 913 $http->timeout = 25; //max. 25 sec 914 $http->keep_alive = false; // we do single ops here, no need for keep-alive 915 $http->agent = 'DokuWiki HTTP Client (Extension Manager)'; 916 917 $data = $http->get($url); 918 if ($data === false) return false; 919 920 $name = ''; 921 if (isset($http->resp_headers['content-disposition'])) { 922 $content_disposition = $http->resp_headers['content-disposition']; 923 $match = []; 924 if ( 925 is_string($content_disposition) && 926 preg_match('/attachment;\s*filename\s*=\s*"([^"]*)"/i', $content_disposition, $match) 927 ) { 928 $name = PhpString::basename($match[1]); 929 } 930 } 931 932 if (!$name) { 933 if (!$defaultName) return false; 934 $name = $defaultName; 935 } 936 937 $file .= $name; 938 939 $fileexists = file_exists($file); 940 $fp = @fopen($file, "w"); 941 if (!$fp) return false; 942 fwrite($fp, $data); 943 fclose($fp); 944 if (!$fileexists && $conf['fperm']) chmod($file, $conf['fperm']); 945 return $name; 946 } 947 948 /** 949 * Download an archive to a protected path 950 * 951 * @param string $url The url to get the archive from 952 * @throws Exception when something goes wrong 953 * @return string The path where the archive was saved 954 */ 955 public function download($url) 956 { 957 // check the url 958 if (!preg_match('/https?:\/\//i', $url)) { 959 throw new Exception($this->getLang('error_badurl')); 960 } 961 962 // try to get the file from the path (used as plugin name fallback) 963 $file = parse_url($url, PHP_URL_PATH); 964 if (is_null($file)) { 965 $file = md5($url); 966 } else { 967 $file = PhpString::basename($file); 968 } 969 970 // create tmp directory for download 971 if (!($tmp = $this->mkTmpDir())) { 972 throw new Exception($this->getLang('error_dircreate')); 973 } 974 975 // download 976 if (!$file = $this->downloadToFile($url, $tmp . '/', $file)) { 977 io_rmdir($tmp, true); 978 throw new Exception(sprintf( 979 $this->getLang('error_download'), 980 '<bdi>' . hsc($url) . '</bdi>' 981 )); 982 } 983 984 return $tmp . '/' . $file; 985 } 986 987 /** 988 * @param string $file The path to the archive that shall be installed 989 * @param bool $overwrite If an already installed plugin should be overwritten 990 * @param string $base The basename of the plugin if it's known 991 * @throws Exception when something went wrong 992 * @return array list of installed extensions 993 */ 994 public function installArchive($file, $overwrite = false, $base = '') 995 { 996 $installed_extensions = []; 997 998 // create tmp directory for decompression 999 if (!($tmp = $this->mkTmpDir())) { 1000 throw new Exception($this->getLang('error_dircreate')); 1001 } 1002 1003 // add default base folder if specified to handle case where zip doesn't contain this 1004 if ($base && !@mkdir($tmp . '/' . $base)) { 1005 throw new Exception($this->getLang('error_dircreate')); 1006 } 1007 1008 // decompress 1009 $this->decompress($file, "$tmp/" . $base); 1010 1011 // search $tmp/$base for the folder(s) that has been created 1012 // move the folder(s) to lib/.. 1013 $result = ['old' => [], 'new' => []]; 1014 $default = ($this->isTemplate() ? 'template' : 'plugin'); 1015 if (!$this->findFolders($result, $tmp . '/' . $base, $default)) { 1016 throw new Exception($this->getLang('error_findfolder')); 1017 } 1018 1019 // choose correct result array 1020 if (count($result['new'])) { 1021 $install = $result['new']; 1022 } else { 1023 $install = $result['old']; 1024 } 1025 1026 if (!count($install)) { 1027 throw new Exception($this->getLang('error_findfolder')); 1028 } 1029 1030 // now install all found items 1031 foreach ($install as $item) { 1032 // where to install? 1033 if ($item['type'] == 'template') { 1034 $target_base_dir = $this->tpllib; 1035 } else { 1036 $target_base_dir = DOKU_PLUGIN; 1037 } 1038 1039 if (!empty($item['base'])) { 1040 // use base set in info.txt 1041 } elseif ($base && count($install) == 1) { 1042 $item['base'] = $base; 1043 } else { 1044 // default - use directory as found in zip 1045 // plugins from github/master without *.info.txt will install in wrong folder 1046 // but using $info->id will make 'code3' fail (which should install in lib/code/..) 1047 $item['base'] = basename($item['tmp']); 1048 } 1049 1050 // check to make sure we aren't overwriting anything 1051 $target = $target_base_dir . $item['base']; 1052 if (!$overwrite && file_exists($target)) { 1053 // this info message is not being exposed via exception, 1054 // so that it's not interrupting the installation 1055 msg(sprintf($this->getLang('msg_nooverwrite'), $item['base'])); 1056 continue; 1057 } 1058 1059 $action = file_exists($target) ? 'update' : 'install'; 1060 1061 // copy action 1062 if ($this->dircopy($item['tmp'], $target)) { 1063 // return info 1064 $id = $item['base']; 1065 if ($item['type'] == 'template') { 1066 $id = 'template:' . $id; 1067 } 1068 $installed_extensions[$id] = [ 1069 'base' => $item['base'], 1070 'type' => $item['type'], 1071 'action' => $action 1072 ]; 1073 } else { 1074 throw new Exception(sprintf( 1075 $this->getLang('error_copy') . DOKU_LF, 1076 '<bdi>' . $item['base'] . '</bdi>' 1077 )); 1078 } 1079 } 1080 1081 // cleanup 1082 if ($tmp) io_rmdir($tmp, true); 1083 if (function_exists('opcache_reset')) { 1084 opcache_reset(); 1085 } 1086 1087 return $installed_extensions; 1088 } 1089 1090 /** 1091 * Find out what was in the extracted directory 1092 * 1093 * Correct folders are searched recursively using the "*.info.txt" configs 1094 * as indicator for a root folder. When such a file is found, it's base 1095 * setting is used (when set). All folders found by this method are stored 1096 * in the 'new' key of the $result array. 1097 * 1098 * For backwards compatibility all found top level folders are stored as 1099 * in the 'old' key of the $result array. 1100 * 1101 * When no items are found in 'new' the copy mechanism should fall back 1102 * the 'old' list. 1103 * 1104 * @author Andreas Gohr <andi@splitbrain.org> 1105 * @param array $result - results are stored here 1106 * @param string $directory - the temp directory where the package was unpacked to 1107 * @param string $default_type - type used if no info.txt available 1108 * @param string $subdir - a subdirectory. do not set. used by recursion 1109 * @return bool - false on error 1110 */ 1111 protected function findFolders(&$result, $directory, $default_type = 'plugin', $subdir = '') 1112 { 1113 $this_dir = "$directory$subdir"; 1114 $dh = @opendir($this_dir); 1115 if (!$dh) return false; 1116 1117 $found_dirs = []; 1118 $found_files = 0; 1119 $found_template_parts = 0; 1120 while (false !== ($f = readdir($dh))) { 1121 if ($f == '.' || $f == '..') continue; 1122 1123 if (is_dir("$this_dir/$f")) { 1124 $found_dirs[] = "$subdir/$f"; 1125 } else { 1126 // it's a file -> check for config 1127 $found_files++; 1128 switch ($f) { 1129 case 'plugin.info.txt': 1130 case 'template.info.txt': 1131 // we have found a clear marker, save and return 1132 $info = []; 1133 $type = explode('.', $f, 2); 1134 $info['type'] = $type[0]; 1135 $info['tmp'] = $this_dir; 1136 $conf = confToHash("$this_dir/$f"); 1137 $info['base'] = basename($conf['base']); 1138 $result['new'][] = $info; 1139 return true; 1140 1141 case 'main.php': 1142 case 'details.php': 1143 case 'mediamanager.php': 1144 case 'style.ini': 1145 $found_template_parts++; 1146 break; 1147 } 1148 } 1149 } 1150 closedir($dh); 1151 1152 // files where found but no info.txt - use old method 1153 if ($found_files) { 1154 $info = []; 1155 $info['tmp'] = $this_dir; 1156 // does this look like a template or should we use the default type? 1157 if ($found_template_parts >= 2) { 1158 $info['type'] = 'template'; 1159 } else { 1160 $info['type'] = $default_type; 1161 } 1162 1163 $result['old'][] = $info; 1164 return true; 1165 } 1166 1167 // we have no files yet -> recurse 1168 foreach ($found_dirs as $found_dir) { 1169 $this->findFolders($result, $directory, $default_type, "$found_dir"); 1170 } 1171 return true; 1172 } 1173 1174 /** 1175 * Decompress a given file to the given target directory 1176 * 1177 * Determines the compression type from the file extension 1178 * 1179 * @param string $file archive to extract 1180 * @param string $target directory to extract to 1181 * @throws Exception 1182 * @return bool 1183 */ 1184 private function decompress($file, $target) 1185 { 1186 // decompression library doesn't like target folders ending in "/" 1187 if (str_ends_with($target, '/')) $target = substr($target, 0, -1); 1188 1189 $ext = $this->guessArchiveType($file); 1190 if (in_array($ext, ['tar', 'bz', 'gz'])) { 1191 try { 1192 $tar = new Tar(); 1193 $tar->open($file); 1194 $tar->extract($target); 1195 } catch (ArchiveIOException $e) { 1196 throw new Exception($this->getLang('error_decompress') . ' ' . $e->getMessage(), $e->getCode(), $e); 1197 } 1198 1199 return true; 1200 } elseif ($ext == 'zip') { 1201 try { 1202 $zip = new Zip(); 1203 $zip->open($file); 1204 $zip->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 } 1211 1212 // the only case when we don't get one of the recognized archive types is 1213 // when the archive file can't be read 1214 throw new Exception($this->getLang('error_decompress') . ' Couldn\'t read archive file'); 1215 } 1216 1217 /** 1218 * Determine the archive type of the given file 1219 * 1220 * Reads the first magic bytes of the given file for content type guessing, 1221 * if neither bz, gz or zip are recognized, tar is assumed. 1222 * 1223 * @author Andreas Gohr <andi@splitbrain.org> 1224 * @param string $file The file to analyze 1225 * @return string|false false if the file can't be read, otherwise an "extension" 1226 */ 1227 private function guessArchiveType($file) 1228 { 1229 $fh = fopen($file, 'rb'); 1230 if (!$fh) return false; 1231 $magic = fread($fh, 5); 1232 fclose($fh); 1233 1234 if (strpos($magic, "\x42\x5a") === 0) return 'bz'; 1235 if (strpos($magic, "\x1f\x8b") === 0) return 'gz'; 1236 if (strpos($magic, "\x50\x4b\x03\x04") === 0) return 'zip'; 1237 return 'tar'; 1238 } 1239 1240 /** 1241 * Copy with recursive sub-directory support 1242 * 1243 * @param string $src filename path to file 1244 * @param string $dst filename path to file 1245 * @return bool|int|string 1246 */ 1247 private function dircopy($src, $dst) 1248 { 1249 global $conf; 1250 1251 if (is_dir($src)) { 1252 if (!$dh = @opendir($src)) return false; 1253 1254 if ($ok = io_mkdir_p($dst)) { 1255 while ($ok && (false !== ($f = readdir($dh)))) { 1256 if ($f == '..' || $f == '.') continue; 1257 $ok = $this->dircopy("$src/$f", "$dst/$f"); 1258 } 1259 } 1260 1261 closedir($dh); 1262 return $ok; 1263 } else { 1264 $existed = file_exists($dst); 1265 1266 if (!@copy($src, $dst)) return false; 1267 if (!$existed && $conf['fperm']) chmod($dst, $conf['fperm']); 1268 @touch($dst, filemtime($src)); 1269 } 1270 1271 return true; 1272 } 1273 1274 /** 1275 * Delete outdated files from updated plugins 1276 * 1277 * @param array $installed 1278 */ 1279 private function removeDeletedfiles($installed) 1280 { 1281 foreach ($installed as $extension) { 1282 // only on update 1283 if ($extension['action'] == 'install') continue; 1284 1285 // get definition file 1286 if ($extension['type'] == 'template') { 1287 $extensiondir = $this->tpllib; 1288 } else { 1289 $extensiondir = DOKU_PLUGIN; 1290 } 1291 $extensiondir = $extensiondir . $extension['base'] . '/'; 1292 $definitionfile = $extensiondir . 'deleted.files'; 1293 if (!file_exists($definitionfile)) continue; 1294 1295 // delete the old files 1296 $list = file($definitionfile); 1297 1298 foreach ($list as $line) { 1299 $line = trim(preg_replace('/#.*$/', '', $line)); 1300 if (!$line) continue; 1301 $file = $extensiondir . $line; 1302 if (!file_exists($file)) continue; 1303 1304 io_rmdir($file, true); 1305 } 1306 } 1307 } 1308 } 1309 1310 // vim:ts=4:sw=4:et:
title
Description
Body
title
Description
Body
title
Description
Body
title
Body