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