xref: /dokuwiki/lib/plugins/extension/Extension.php (revision 077f55feebe4a4f38e51ad49cb3c36270f5ee390)
1<?php
2
3namespace dokuwiki\plugin\extension;
4
5use dokuwiki\Extension\PluginController;
6use dokuwiki\Utf8\PhpString;
7use RuntimeException;
8
9class Extension
10{
11    public const TYPE_PLUGIN = 'plugin';
12    public 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 = fullpath($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        // disallow current template to be uninstalled
430        if($this->isTemplate() && ($this->getBase() === $conf['template'])) return true;
431
432        /** @var PluginController $plugin_controller */
433        global $plugin_controller;
434        $cascade = $plugin_controller->getCascade();
435        return ($cascade['protected'][$this->getId()] ?? false);
436    }
437
438    /**
439     * Is the extension installed in the correct directory?
440     *
441     * @return bool
442     */
443    public function isInWrongFolder()
444    {
445        if (!$this->isInstalled()) return false;
446        return $this->getInstallDir() != $this->currentDir;
447    }
448
449    /**
450     * Is the extension enabled?
451     *
452     * @return bool
453     */
454    public function isEnabled()
455    {
456        global $conf;
457        if ($this->isTemplate()) {
458            return ($conf['template'] == $this->getBase());
459        }
460
461        /* @var PluginController $plugin_controller */
462        global $plugin_controller;
463        return $plugin_controller->isEnabled($this->base);
464    }
465
466    /**
467     * Has the download URL changed since the last download?
468     *
469     * @return bool
470     */
471    public function hasChangedURL()
472    {
473        $last = $this->getManager()->getDownloadURL();
474        if (!$last) return false;
475        return $last !== $this->getDownloadURL();
476    }
477
478    /**
479     * Is an update available for this extension?
480     *
481     * @return bool
482     */
483    public function isUpdateAvailable()
484    {
485        if ($this->isBundled()) return false; // bundled extensions are never updated
486        $self = $this->getInstalledVersion();
487        $remote = $this->getLastUpdate();
488        return $self < $remote;
489    }
490
491    // endregion
492
493    // region Remote Info
494
495    /**
496     * Get the date of the last available update
497     *
498     * @return string yyyy-mm-dd
499     */
500    public function getLastUpdate()
501    {
502        return $this->getRemoteTag('lastupdate');
503    }
504
505    /**
506     * Get a list of tags this extension is tagged with at dokuwiki.org
507     *
508     * @return string[]
509     */
510    public function getTags()
511    {
512        return $this->getRemoteTag('tags', []);
513    }
514
515    /**
516     * Get the popularity of the extension
517     *
518     * This is a float between 0 and 1
519     *
520     * @return float
521     */
522    public function getPopularity()
523    {
524        return (float)$this->getRemoteTag('popularity', 0);
525    }
526
527    /**
528     * Get the text of the update message if there is any
529     *
530     * @return string
531     */
532    public function getUpdateMessage()
533    {
534        return $this->getRemoteTag('updatemessage');
535    }
536
537    /**
538     * Get the text of the security warning if there is any
539     *
540     * @return string
541     */
542    public function getSecurityWarning()
543    {
544        return $this->getRemoteTag('securitywarning');
545    }
546
547    /**
548     * Get the text of the security issue if there is any
549     *
550     * @return string
551     */
552    public function getSecurityIssue()
553    {
554        return $this->getRemoteTag('securityissue');
555    }
556
557    /**
558     * Get the URL of the screenshot of the extension if there is any
559     *
560     * @return string
561     */
562    public function getScreenshotURL()
563    {
564        return $this->getRemoteTag('screenshoturl');
565    }
566
567    /**
568     * Get the URL of the thumbnail of the extension if there is any
569     *
570     * @return string
571     */
572    public function getThumbnailURL()
573    {
574        return $this->getRemoteTag('thumbnailurl');
575    }
576
577    /**
578     * Get the download URL of the extension if there is any
579     *
580     * @return string
581     */
582    public function getDownloadURL()
583    {
584        return $this->getRemoteTag('downloadurl');
585    }
586
587    /**
588     * Get the bug tracker URL of the extension if there is any
589     *
590     * @return string
591     */
592    public function getBugtrackerURL()
593    {
594        return $this->getRemoteTag('bugtracker');
595    }
596
597    /**
598     * Get the URL of the source repository if there is any
599     *
600     * @return string
601     */
602    public function getSourcerepoURL()
603    {
604        return $this->getRemoteTag('sourcerepo');
605    }
606
607    /**
608     * Get the donation URL of the extension if there is any
609     *
610     * @return string
611     */
612    public function getDonationURL()
613    {
614        return $this->getRemoteTag('donationurl');
615    }
616
617    /**
618     * Get a list of extensions that are similar to this one
619     *
620     * @return string[]
621     */
622    public function getSimilarList()
623    {
624        return $this->getRemoteTag('similar', []);
625    }
626
627    /**
628     * Get a list of extensions that are marked as conflicting with this one
629     *
630     * @return string[]
631     */
632    public function getConflictList()
633    {
634        return $this->getRemoteTag('conflicts', []);
635    }
636
637    /**
638     * Get a list of DokuWiki versions this plugin is marked as compatible with
639     *
640     * @return string[][] date -> version
641     */
642    public function getCompatibleVersions()
643    {
644        return $this->getRemoteTag('compatible', []);
645    }
646
647    // endregion
648
649    // region Actions
650
651    /**
652     * Install or update the extension
653     *
654     * @throws Exception
655     */
656    public function installOrUpdate()
657    {
658        $installer = new Installer(true);
659        $installer->installExtension($this);
660    }
661
662    /**
663     * Uninstall the extension
664     * @throws Exception
665     */
666    public function uninstall()
667    {
668        $installer = new Installer(true);
669        $installer->uninstall($this);
670    }
671
672    /**
673     * Toggle the extension between enabled and disabled
674     * @return void
675     * @throws Exception
676     */
677    public function toggle()
678    {
679        if ($this->isEnabled()) {
680            $this->disable();
681        } else {
682            $this->enable();
683        }
684    }
685
686    /**
687     * Enable the extension
688     * @todo I'm unsure if this code should be here or part of Installer
689     * @throws Exception
690     */
691    public function enable()
692    {
693        if ($this->isTemplate()) throw new Exception('notimplemented');
694        if (!$this->isInstalled()) throw new Exception('error_notinstalled', [$this->getId()]);
695        if ($this->isEnabled()) throw new Exception('error_alreadyenabled', [$this->getId()]);
696
697        /* @var PluginController $plugin_controller */
698        global $plugin_controller;
699        if (!$plugin_controller->enable($this->base)) {
700            throw new Exception('pluginlistsaveerror');
701        }
702        Installer::purgeCache();
703    }
704
705    /**
706     * Disable the extension
707     * @todo I'm unsure if this code should be here or part of Installer
708     * @throws Exception
709     */
710    public function disable()
711    {
712        if ($this->isTemplate()) throw new Exception('notimplemented');
713        if (!$this->isInstalled()) throw new Exception('error_notinstalled', [$this->getId()]);
714        if (!$this->isEnabled()) throw new Exception('error_alreadydisabled', [$this->getId()]);
715        if ($this->isProtected()) throw new Exception('error_disable_protected', [$this->getId()]);
716
717        /* @var PluginController $plugin_controller */
718        global $plugin_controller;
719        if (!$plugin_controller->disable($this->base)) {
720            throw new Exception('pluginlistsaveerror');
721        }
722        Installer::purgeCache();
723    }
724
725    // endregion
726
727    // region Meta Data Management
728
729    /**
730     * Access the Manager for this extension
731     *
732     * @return Manager
733     */
734    public function getManager()
735    {
736        if (!$this->manager instanceof Manager) {
737            $this->manager = new Manager($this);
738        }
739        return $this->manager;
740    }
741
742    /**
743     * Reads the info file of the extension if available and fills the localInfo array
744     */
745    protected function readLocalInfo()
746    {
747        if (!$this->getCurrentDir()) return;
748        $file = $this->currentDir . '/' . $this->type . '.info.txt';
749        if (!is_readable($file)) return;
750        $this->localInfo = confToHash($file, true);
751        $this->localInfo = array_filter($this->localInfo); // remove all falsy keys
752    }
753
754    /**
755     * Fetches the remote info from the repository
756     *
757     * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case
758     */
759    protected function loadRemoteInfo()
760    {
761        if ($this->remoteInfo) return;
762        $remote = Repository::getInstance();
763        try {
764            $this->remoteInfo = (array)$remote->getExtensionData($this->getId());
765        } catch (Exception $e) {
766            $this->remoteInfo = [];
767        }
768    }
769
770    /**
771     * Read information from either local or remote info
772     *
773     * Always prefers local info over remote info. Giving multiple keys is useful when the
774     * key has been renamed in the past or if local and remote keys might differ.
775     *
776     * @param string|string[] $tag one or multiple keys to check
777     * @param mixed $default
778     * @return mixed
779     */
780    protected function getTag($tag, $default = '')
781    {
782        foreach ((array)$tag as $t) {
783            if (isset($this->localInfo[$t])) return $this->localInfo[$t];
784        }
785
786        return $this->getRemoteTag($tag, $default);
787    }
788
789    /**
790     * Read information from remote info
791     *
792     * @param string|string[] $tag one or mutiple keys to check
793     * @param mixed $default
794     * @return mixed
795     */
796    protected function getRemoteTag($tag, $default = '')
797    {
798        $this->loadRemoteInfo();
799        foreach ((array)$tag as $t) {
800            if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t];
801        }
802        return $default;
803    }
804
805    // endregion
806
807    // region utilities
808
809    /**
810     * Convert an extension id to a type and base
811     *
812     * @param string $id
813     * @return array [type, base]
814     */
815    protected function idToTypeBase($id)
816    {
817        [$type, $base] = sexplode(':', $id, 2);
818        if ($base === null) {
819            $base = $type;
820            $type = self::TYPE_PLUGIN;
821        } elseif ($type === self::TYPE_TEMPLATE) {
822            $type = self::TYPE_TEMPLATE;
823        } else {
824            throw new RuntimeException('Invalid extension id: ' . $id);
825        }
826
827        return [$type, $base];
828    }
829    /**
830     * @return string
831     */
832    public function __toString()
833    {
834        return $this->getId();
835    }
836
837    // endregion
838}
839