[ Index ]

PHP Cross Reference of DokuWiki

title

Body

[close]

/lib/plugins/extension/helper/ -> extension.php (source)

   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       * 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: