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