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