xref: /dokuwiki/lib/plugins/extension/Extension.php (revision 8fe483c9dd38f0052abe729cc74057c9cdf54ad3)
1<?php
2
3namespace dokuwiki\plugin\extension;
4
5use dokuwiki\Extension\PluginController;
6use dokuwiki\Utf8\PhpString;
7use RuntimeException;
8
9class Extension
10{
11    const TYPE_PLUGIN = 'plugin';
12    const TYPE_TEMPLATE = 'template';
13
14    /** @var string "plugin"|"template" */
15    protected string $type = self::TYPE_PLUGIN;
16
17    /** @var string The base name of this extension */
18    protected string $base;
19
20    /** @var string The current location of this extension */
21    protected string $currentDir = '';
22
23    /** @var array The local info array of the extension */
24    protected array $localInfo = [];
25
26    /** @var array The remote info array of the extension */
27    protected array $remoteInfo = [];
28
29    /** @var Manager|null The manager for this extension */
30    protected ?Manager $manager = null;
31
32    // region Constructors
33
34    /**
35     * The main constructor is private to force the use of the factory methods
36     */
37    protected function __construct()
38    {
39    }
40
41    /**
42     * Initializes an extension from an id
43     *
44     * @param string $id The id of the extension
45     * @return Extension
46     */
47    public static function createFromId($id)
48    {
49        $extension = new self();
50        $extension->initFromId($id);
51        return $extension;
52    }
53
54    protected function initFromId($id)
55    {
56        [$type, $base] = $this->idToTypeBase($id);
57        $this->type = $type;
58        $this->base = $base;
59        $this->readLocalInfo();
60    }
61
62    /**
63     * Initializes an extension from a directory
64     *
65     * The given directory might be the one where the extension has already been installed to
66     * or it might be the extracted source in some temporary directory.
67     *
68     * @param string $dir Where the extension code is currently located
69     * @param string|null $type TYPE_PLUGIN|TYPE_TEMPLATE, null for auto-detection
70     * @param string $base The base name of the extension, null for auto-detection
71     * @return Extension
72     */
73    public static function createFromDirectory($dir, $type = null, $base = null)
74    {
75        $extension = new self();
76        $extension->initFromDirectory($dir, $type, $base);
77        return $extension;
78    }
79
80    protected function initFromDirectory($dir, $type = null, $base = null)
81    {
82        if (!is_dir($dir)) throw new RuntimeException('Directory not found: ' . $dir);
83        $this->currentDir = realpath($dir);
84
85        if ($type === null || $type === self::TYPE_TEMPLATE) {
86            if (
87                file_exists($dir . '/template.info.txt') ||
88                file_exists($dir . '/style.ini') ||
89                file_exists($dir . '/main.php') ||
90                file_exists($dir . '/detail.php') ||
91                file_exists($dir . '/mediamanager.php')
92            ) {
93                $this->type = self::TYPE_TEMPLATE;
94            }
95        } else {
96            $this->type = self::TYPE_PLUGIN;
97        }
98
99        $this->readLocalInfo();
100
101        if ($base !== null) {
102            $this->base = $base;
103        } elseif (isset($this->localInfo['base'])) {
104            $this->base = $this->localInfo['base'];
105        } else {
106            $this->base = basename($dir);
107        }
108    }
109
110    /**
111     * Initializes an extension from remote data
112     *
113     * @param array $data The data as returned by the repository api
114     * @return Extension
115     */
116    public static function createFromRemoteData($data)
117    {
118        $extension = new self();
119        $extension->initFromRemoteData($data);
120        return $extension;
121    }
122
123    protected function initFromRemoteData($data)
124    {
125        if (!isset($data['plugin'])) throw new RuntimeException('Invalid remote data');
126
127        [$type, $base] = $this->idToTypeBase($data['plugin']);
128        $this->remoteInfo = $data;
129        $this->type = $type;
130        $this->base = $base;
131
132        if ($this->isInstalled()) {
133            $this->currentDir = $this->getInstallDir();
134            $this->readLocalInfo();
135        }
136    }
137
138    // endregion
139
140    // region Getters
141
142    /**
143     * @param bool $wrap If true, the id is wrapped in backticks
144     * @return string The extension id (same as base but prefixed with "template:" for templates)
145     */
146    public function getId($wrap=false)
147    {
148        if ($this->type === self::TYPE_TEMPLATE) {
149            $id = self::TYPE_TEMPLATE . ':' . $this->base;
150        } else {
151            $id = $this->base;
152        }
153        if($wrap) $id = "`$id`";
154        return $id;
155    }
156
157    /**
158     * Get the base name of this extension
159     *
160     * @return string
161     */
162    public function getBase()
163    {
164        return $this->base;
165    }
166
167    /**
168     * Get the type of the extension
169     *
170     * @return string "plugin"|"template"
171     */
172    public function getType()
173    {
174        return $this->type;
175    }
176
177    /**
178     * The current directory of the extension
179     *
180     * @return string|null
181     */
182    public function getCurrentDir()
183    {
184        // recheck that the current currentDir is still valid
185        if ($this->currentDir && !is_dir($this->currentDir)) {
186            $this->currentDir = '';
187        }
188
189        // if the extension is installed, then the currentDir is the install dir!
190        if (!$this->currentDir && $this->isInstalled()) {
191            $this->currentDir = $this->getInstallDir();
192        }
193
194        return $this->currentDir;
195    }
196
197    /**
198     * Get the directory where this extension should be installed in
199     *
200     * Note: this does not mean that the extension is actually installed there
201     *
202     * @return string
203     */
204    public function getInstallDir()
205    {
206        if ($this->isTemplate()) {
207            $dir = dirname(tpl_incdir()) . '/' . $this->base;
208        } else {
209            $dir = DOKU_PLUGIN . $this->base;
210        }
211
212        return fullpath($dir);
213    }
214
215
216    /**
217     * Get the display name of the extension
218     *
219     * @return string
220     */
221    public function getDisplayName()
222    {
223        return $this->getTag('name', PhpString::ucwords($this->getBase() . ' ' . $this->getType()));
224    }
225
226    /**
227     * Get the author name of the extension
228     *
229     * @return string Returns an empty string if the author info is missing
230     */
231    public function getAuthor()
232    {
233        return $this->getTag('author');
234    }
235
236    /**
237     * Get the email of the author of the extension if there is any
238     *
239     * @return string Returns an empty string if the email info is missing
240     */
241    public function getEmail()
242    {
243        // email is only in the local data
244        return $this->localInfo['email'] ?? '';
245    }
246
247    /**
248     * Get the email id, i.e. the md5sum of the email
249     *
250     * @return string Empty string if no email is available
251     */
252    public function getEmailID()
253    {
254        if (!empty($this->remoteInfo['emailid'])) return $this->remoteInfo['emailid'];
255        if (!empty($this->localInfo['email'])) return md5($this->localInfo['email']);
256        return '';
257    }
258
259    /**
260     * Get the description of the extension
261     *
262     * @return string Empty string if no description is available
263     */
264    public function getDescription()
265    {
266        return $this->getTag(['desc', 'description']);
267    }
268
269    /**
270     * Get the URL of the extension, usually a page on dokuwiki.org
271     *
272     * @return string
273     */
274    public function getURL()
275    {
276        return $this->getTag(
277            'url',
278            'https://www.dokuwiki.org/' .
279            ($this->isTemplate() ? 'template' : 'plugin') . ':' . $this->getBase()
280        );
281    }
282
283    /**
284     * Get the version of the extension that is actually installed
285     *
286     * Returns an empty string if the version is not available
287     *
288     * @return string
289     */
290    public function getInstalledVersion()
291    {
292        return $this->localInfo['date'] ?? '';
293    }
294
295    /**
296     * Get the types of components this extension provides
297     *
298     * @return array int -> type
299     */
300    public function getComponentTypes ()
301    {
302        // for installed extensions we can check the files
303        if($this->isInstalled()) {
304            if($this->isTemplate()) {
305                return ['Template'];
306            } else {
307                $types = [];
308                foreach (['Admin', 'Action', 'Syntax', 'Renderer', 'Helper', 'CLI'] as $type) {
309                    $check = strtolower($type);
310                    if(
311                        file_exists($this->getInstallDir() . '/' . $check . '.php') ||
312                        is_dir($this->getInstallDir() . '/' . $check)
313                    ) {
314                        $types[] = $type;
315                    }
316                }
317                return $types;
318            }
319        }
320        // still, here? use the remote info
321        return $this->getTag('types', []);
322    }
323
324    /**
325     * Get a list of extension ids this extension depends on
326     *
327     * @return string[]
328     */
329    public function getDependencyList()
330    {
331        return $this->getTag('depends', []);
332    }
333
334    /**
335     * Return the minimum PHP version required by the extension
336     *
337     * Empty if not set
338     *
339     * @return string
340     */
341    public function getMinimumPHPVersion()
342    {
343        return $this->getTag('phpmin', '');
344    }
345
346    /**
347     * Return the minimum PHP version supported by the extension
348     *
349     * @return string
350     */
351    public function getMaximumPHPVersion()
352    {
353        return $this->getTag('phpmax', '');
354    }
355
356    /**
357     * Is this extension a template?
358     *
359     * @return bool false if it is a plugin
360     */
361    public function isTemplate()
362    {
363        return $this->type === self::TYPE_TEMPLATE;
364    }
365
366    /**
367     * Is the extension installed locally?
368     *
369     * @return bool
370     */
371    public function isInstalled()
372    {
373        return is_dir($this->getInstallDir());
374    }
375
376    /**
377     * Is the extension under git control?
378     *
379     * @return bool
380     */
381    public function isGitControlled()
382    {
383        if (!$this->isInstalled()) return false;
384        return file_exists($this->getInstallDir() . '/.git');
385    }
386
387    /**
388     * If the extension is bundled
389     *
390     * @return bool If the extension is bundled
391     */
392    public function isBundled()
393    {
394        $this->loadRemoteInfo();
395        return $this->remoteInfo['bundled'] ?? in_array(
396            $this->getId(),
397            [
398                'authad',
399                'authldap',
400                'authpdo',
401                'authplain',
402                'acl',
403                'config',
404                'extension',
405                'info',
406                'popularity',
407                'revert',
408                'safefnrecode',
409                'styling',
410                'testing',
411                'usermanager',
412                'logviewer',
413                'template:dokuwiki'
414            ]
415        );
416    }
417
418    /**
419     * Is the extension protected against any modification (disable/uninstall)
420     *
421     * @return bool if the extension is protected
422     */
423    public function isProtected()
424    {
425        // never allow deinstalling the current auth plugin:
426        global $conf;
427        if ($this->getId() == $conf['authtype']) return true;
428
429        // FIXME disallow current template to be uninstalled
430
431        /** @var PluginController $plugin_controller */
432        global $plugin_controller;
433        $cascade = $plugin_controller->getCascade();
434        return ($cascade['protected'][$this->getId()] ?? false);
435    }
436
437    /**
438     * Is the extension installed in the correct directory?
439     *
440     * @return bool
441     */
442    public function isInWrongFolder()
443    {
444        if(!$this->isInstalled()) return false;
445        return $this->getInstallDir() != $this->currentDir;
446    }
447
448    /**
449     * Is the extension enabled?
450     *
451     * @return bool
452     */
453    public function isEnabled()
454    {
455        global $conf;
456        if ($this->isTemplate()) {
457            return ($conf['template'] == $this->getBase());
458        }
459
460        /* @var PluginController $plugin_controller */
461        global $plugin_controller;
462        return $plugin_controller->isEnabled($this->base);
463    }
464
465    /**
466     * Has the download URL changed since the last download?
467     *
468     * @return bool
469     */
470    public function hasChangedURL()
471    {
472        $last = $this->getManager()->getDownloadURL();
473        if(!$last) return false;
474        return $last !== $this->getDownloadURL();
475    }
476
477    /**
478     * Is an update available for this extension?
479     *
480     * @return bool
481     */
482    public function isUpdateAvailable()
483    {
484        if($this->isBundled()) return false; // bundled extensions are never updated
485        $self = $this->getInstalledVersion();
486        $remote = $this->getLastUpdate();
487        return $self < $remote;
488    }
489
490    // endregion
491
492    // region Remote Info
493
494    /**
495     * Get the date of the last available update
496     *
497     * @return string yyyy-mm-dd
498     */
499    public function getLastUpdate()
500    {
501        return $this->getRemoteTag('lastupdate');
502    }
503
504    /**
505     * Get a list of tags this extension is tagged with at dokuwiki.org
506     *
507     * @return string[]
508     */
509    public function getTags()
510    {
511        return $this->getRemoteTag('tags', []);
512    }
513
514    /**
515     * Get the popularity of the extension
516     *
517     * This is a float between 0 and 1
518     *
519     * @return float
520     */
521    public function getPopularity()
522    {
523        return (float)$this->getRemoteTag('popularity', 0);
524    }
525
526    /**
527     * Get the text of the update message if there is any
528     *
529     * @return string
530     */
531    public function getUpdateMessage()
532    {
533        return $this->getRemoteTag('updatemessage');
534    }
535
536    /**
537     * Get the text of the security warning if there is any
538     *
539     * @return string
540     */
541    public function getSecurityWarning()
542    {
543        return $this->getRemoteTag('securitywarning');
544    }
545
546    /**
547     * Get the text of the security issue if there is any
548     *
549     * @return string
550     */
551    public function getSecurityIssue()
552    {
553        return $this->getRemoteTag('securityissue');
554    }
555
556    /**
557     * Get the URL of the screenshot of the extension if there is any
558     *
559     * @return string
560     */
561    public function getScreenshotURL()
562    {
563        return $this->getRemoteTag('screenshoturl');
564    }
565
566    /**
567     * Get the URL of the thumbnail of the extension if there is any
568     *
569     * @return string
570     */
571    public function getThumbnailURL()
572    {
573        return $this->getRemoteTag('thumbnailurl');
574    }
575
576    /**
577     * Get the download URL of the extension if there is any
578     *
579     * @return string
580     */
581    public function getDownloadURL()
582    {
583        return $this->getRemoteTag('downloadurl');
584    }
585
586    /**
587     * Get the bug tracker URL of the extension if there is any
588     *
589     * @return string
590     */
591    public function getBugtrackerURL()
592    {
593        return $this->getRemoteTag('bugtracker');
594    }
595
596    /**
597     * Get the URL of the source repository if there is any
598     *
599     * @return string
600     */
601    public function getSourcerepoURL()
602    {
603        return $this->getRemoteTag('sourcerepo');
604    }
605
606    /**
607     * Get the donation URL of the extension if there is any
608     *
609     * @return string
610     */
611    public function getDonationURL()
612    {
613        return $this->getRemoteTag('donationurl');
614    }
615
616    /**
617     * Get a list of extensions that are similar to this one
618     *
619     * @return string[]
620     */
621    public function getSimilarList()
622    {
623        return $this->getRemoteTag('similar', []);
624    }
625
626    /**
627     * Get a list of extensions that are marked as conflicting with this one
628     *
629     * @return string[]
630     */
631    public function getConflictList()
632    {
633        return $this->getRemoteTag('conflicts', []);
634    }
635
636    /**
637     * Get a list of DokuWiki versions this plugin is marked as compatible with
638     *
639     * @return string[][] date -> version
640     */
641    public function getCompatibleVersions()
642    {
643        return $this->getRemoteTag('compatible', []);
644    }
645
646    // endregion
647
648    // region Actions
649
650    /**
651     * Install or update the extension
652     *
653     * @throws Exception
654     */
655    public function installOrUpdate()
656    {
657        $installer = new Installer(true);
658        $installer->installExtension($this);
659    }
660
661    /**
662     * Uninstall the extension
663     * @throws Exception
664     */
665    public function uninstall()
666    {
667        $installer = new Installer(true);
668        $installer->uninstall($this);
669    }
670
671    /**
672     * Toggle the extension between enabled and disabled
673     * @return void
674     * @throws Exception
675     */
676    public function toggle()
677    {
678        if($this->isEnabled()) {
679            $this->disable();
680        } else {
681            $this->enable();
682        }
683    }
684
685    /**
686     * Enable the extension
687     * @todo I'm unsure if this code should be here or part of Installer
688     * @throws Exception
689     */
690    public function enable()
691    {
692        if ($this->isTemplate()) throw new Exception('notimplemented');
693        if (!$this->isInstalled()) throw new Exception('error_notinstalled', [$this->getId()]);
694        if ($this->isEnabled()) throw new Exception('error_alreadyenabled', [$this->getId()]);
695
696        /* @var PluginController $plugin_controller */
697        global $plugin_controller;
698        if (!$plugin_controller->enable($this->base)) {
699            throw new Exception('pluginlistsaveerror');
700        }
701        Installer::purgeCache();
702    }
703
704    /**
705     * Disable the extension
706     * @todo I'm unsure if this code should be here or part of Installer
707     * @throws Exception
708     */
709    public function disable()
710    {
711        if ($this->isTemplate()) throw new Exception('notimplemented');
712        if (!$this->isInstalled()) throw new Exception('error_notinstalled', [$this->getId()]);
713        if (!$this->isEnabled()) throw new Exception('error_alreadydisabled', [$this->getId()]);
714        if ($this->isProtected()) throw new Exception('error_disable_protected', [$this->getId()]);
715
716        /* @var PluginController $plugin_controller */
717        global $plugin_controller;
718        if (!$plugin_controller->disable($this->base)) {
719            throw new Exception('pluginlistsaveerror');
720        }
721        Installer::purgeCache();
722    }
723
724    // endregion
725
726    // region Meta Data Management
727
728    /**
729     * Access the Manager for this extension
730     *
731     * @return Manager
732     */
733    public function getManager()
734    {
735        if ($this->manager === null) {
736            $this->manager = new Manager($this);
737        }
738        return $this->manager;
739    }
740
741    /**
742     * Reads the info file of the extension if available and fills the localInfo array
743     */
744    protected function readLocalInfo()
745    {
746        if (!$this->getCurrentDir()) return;
747        $file = $this->currentDir . '/' . $this->type . '.info.txt';
748        if (!is_readable($file)) return;
749        $this->localInfo = confToHash($file, true);
750        $this->localInfo = array_filter($this->localInfo); // remove all falsy keys
751    }
752
753    /**
754     * Fetches the remote info from the repository
755     *
756     * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case
757     */
758    protected function loadRemoteInfo()
759    {
760        if ($this->remoteInfo) return;
761        $remote = Repository::getInstance();
762        try {
763            $this->remoteInfo = (array)$remote->getExtensionData($this->getId());
764        } catch (Exception $e) {
765            $this->remoteInfo = [];
766        }
767    }
768
769    /**
770     * Read information from either local or remote info
771     *
772     * Always prefers local info over remote info. Giving multiple keys is useful when the
773     * key has been renamed in the past or if local and remote keys might differ.
774     *
775     * @param string|string[] $tag one or multiple keys to check
776     * @param mixed $default
777     * @return mixed
778     */
779    protected function getTag($tag, $default = '')
780    {
781        foreach ((array)$tag as $t) {
782            if (isset($this->localInfo[$t])) return $this->localInfo[$t];
783        }
784
785        return $this->getRemoteTag($tag, $default);
786    }
787
788    /**
789     * Read information from remote info
790     *
791     * @param string|string[] $tag one or mutiple keys to check
792     * @param mixed $default
793     * @return mixed
794     */
795    protected function getRemoteTag($tag, $default = '')
796    {
797        $this->loadRemoteInfo();
798        foreach ((array)$tag as $t) {
799            if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t];
800        }
801        return $default;
802    }
803
804    // endregion
805
806    // region utilities
807
808    /**
809     * Convert an extension id to a type and base
810     *
811     * @param string $id
812     * @return array [type, base]
813     */
814    protected function idToTypeBase($id)
815    {
816        [$type, $base] = sexplode(':', $id, 2);
817        if ($base === null) {
818            $base = $type;
819            $type = self::TYPE_PLUGIN;
820        } elseif ($type === self::TYPE_TEMPLATE) {
821            $type = self::TYPE_TEMPLATE;
822        } else {
823            throw new RuntimeException('Invalid extension id: ' . $id);
824        }
825
826        return [$type, $base];
827    }
828    /**
829     * @return string
830     */
831    public function __toString()
832    {
833        return $this->getId();
834    }
835
836    // endregion
837}
838