xref: /dokuwiki/lib/plugins/extension/Extension.php (revision b69d74f1e111c77de08eb7f64e7f77368a537e44)
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     * Get a list of extensions that are currently installed, enabled and depend on this extension
336     *
337     * @return Extension[]
338     */
339    public function getDependants()
340    {
341        $local = new Local();
342        $extensions = $local->getExtensions();
343        $dependants = [];
344        foreach ($extensions as $extension) {
345            if (
346                in_array($this->getId(), $extension->getDependencyList()) &&
347                $extension->isEnabled()
348            ) {
349                $dependants[$extension->getId()] = $extension;
350            }
351        }
352        return $dependants;
353    }
354
355    /**
356     * Return the minimum PHP version required by the extension
357     *
358     * Empty if not set
359     *
360     * @return string
361     */
362    public function getMinimumPHPVersion()
363    {
364        return $this->getTag('phpmin', '');
365    }
366
367    /**
368     * Return the minimum PHP version supported by the extension
369     *
370     * @return string
371     */
372    public function getMaximumPHPVersion()
373    {
374        return $this->getTag('phpmax', '');
375    }
376
377    /**
378     * Is this extension a template?
379     *
380     * @return bool false if it is a plugin
381     */
382    public function isTemplate()
383    {
384        return $this->type === self::TYPE_TEMPLATE;
385    }
386
387    /**
388     * Is the extension installed locally?
389     *
390     * @return bool
391     */
392    public function isInstalled()
393    {
394        return is_dir($this->getInstallDir());
395    }
396
397    /**
398     * Is the extension under git control?
399     *
400     * @return bool
401     */
402    public function isGitControlled()
403    {
404        if (!$this->isInstalled()) return false;
405        return file_exists($this->getInstallDir() . '/.git');
406    }
407
408    /**
409     * If the extension is bundled
410     *
411     * @return bool If the extension is bundled
412     */
413    public function isBundled()
414    {
415        $this->loadRemoteInfo();
416        return $this->remoteInfo['bundled'] ?? in_array(
417            $this->getId(),
418            [
419                'authad',
420                'authldap',
421                'authpdo',
422                'authplain',
423                'acl',
424                'config',
425                'extension',
426                'info',
427                'popularity',
428                'revert',
429                'safefnrecode',
430                'styling',
431                'testing',
432                'usermanager',
433                'logviewer',
434                'template:dokuwiki'
435            ]
436        );
437    }
438
439    /**
440     * Is the extension protected against any modification (disable/uninstall)
441     *
442     * @return bool if the extension is protected
443     */
444    public function isProtected()
445    {
446        // never allow deinstalling the current auth plugin:
447        global $conf;
448        if ($this->getId() == $conf['authtype']) return true;
449
450        // disallow current template to be uninstalled
451        if ($this->isTemplate() && ($this->getBase() === $conf['template'])) return true;
452
453        /** @var PluginController $plugin_controller */
454        global $plugin_controller;
455        $cascade = $plugin_controller->getCascade();
456        return ($cascade['protected'][$this->getId()] ?? false);
457    }
458
459    /**
460     * Is the extension installed in the correct directory?
461     *
462     * @return bool
463     */
464    public function isInWrongFolder()
465    {
466        if (!$this->isInstalled()) return false;
467        return $this->getInstallDir() != $this->currentDir;
468    }
469
470    /**
471     * Is the extension enabled?
472     *
473     * @return bool
474     */
475    public function isEnabled()
476    {
477        global $conf;
478        if ($this->isTemplate()) {
479            return ($conf['template'] == $this->getBase());
480        }
481
482        /* @var PluginController $plugin_controller */
483        global $plugin_controller;
484        return $plugin_controller->isEnabled($this->base);
485    }
486
487    /**
488     * Has the download URL changed since the last download?
489     *
490     * @return bool
491     */
492    public function hasChangedURL()
493    {
494        $last = $this->getManager()->getDownloadURL();
495        if (!$last) return false;
496        return $last !== $this->getDownloadURL();
497    }
498
499    /**
500     * Is an update available for this extension?
501     *
502     * @return bool
503     */
504    public function isUpdateAvailable()
505    {
506        if ($this->isBundled()) return false; // bundled extensions are never updated
507        $self = $this->getInstalledVersion();
508        $remote = $this->getLastUpdate();
509        return $self < $remote;
510    }
511
512    // endregion
513
514    // region Remote Info
515
516    /**
517     * Get the date of the last available update
518     *
519     * @return string yyyy-mm-dd
520     */
521    public function getLastUpdate()
522    {
523        return $this->getRemoteTag('lastupdate');
524    }
525
526    /**
527     * Get a list of tags this extension is tagged with at dokuwiki.org
528     *
529     * @return string[]
530     */
531    public function getTags()
532    {
533        return $this->getRemoteTag('tags', []);
534    }
535
536    /**
537     * Get the popularity of the extension
538     *
539     * This is a float between 0 and 1
540     *
541     * @return float
542     */
543    public function getPopularity()
544    {
545        return (float)$this->getRemoteTag('popularity', 0);
546    }
547
548    /**
549     * Get the text of the update message if there is any
550     *
551     * @return string
552     */
553    public function getUpdateMessage()
554    {
555        return $this->getRemoteTag('updatemessage');
556    }
557
558    /**
559     * Get the text of the security warning if there is any
560     *
561     * @return string
562     */
563    public function getSecurityWarning()
564    {
565        return $this->getRemoteTag('securitywarning');
566    }
567
568    /**
569     * Get the text of the security issue if there is any
570     *
571     * @return string
572     */
573    public function getSecurityIssue()
574    {
575        return $this->getRemoteTag('securityissue');
576    }
577
578    /**
579     * Get the URL of the screenshot of the extension if there is any
580     *
581     * @return string
582     */
583    public function getScreenshotURL()
584    {
585        return $this->getRemoteTag('screenshoturl');
586    }
587
588    /**
589     * Get the URL of the thumbnail of the extension if there is any
590     *
591     * @return string
592     */
593    public function getThumbnailURL()
594    {
595        return $this->getRemoteTag('thumbnailurl');
596    }
597
598    /**
599     * Get the download URL of the extension if there is any
600     *
601     * @return string
602     */
603    public function getDownloadURL()
604    {
605        return $this->getRemoteTag('downloadurl');
606    }
607
608    /**
609     * Get the bug tracker URL of the extension if there is any
610     *
611     * @return string
612     */
613    public function getBugtrackerURL()
614    {
615        return $this->getRemoteTag('bugtracker');
616    }
617
618    /**
619     * Get the URL of the source repository if there is any
620     *
621     * @return string
622     */
623    public function getSourcerepoURL()
624    {
625        return $this->getRemoteTag('sourcerepo');
626    }
627
628    /**
629     * Get the donation URL of the extension if there is any
630     *
631     * @return string
632     */
633    public function getDonationURL()
634    {
635        return $this->getRemoteTag('donationurl');
636    }
637
638    /**
639     * Get a list of extensions that are similar to this one
640     *
641     * @return string[]
642     */
643    public function getSimilarList()
644    {
645        return $this->getRemoteTag('similar', []);
646    }
647
648    /**
649     * Get a list of extensions that are marked as conflicting with this one
650     *
651     * @return string[]
652     */
653    public function getConflictList()
654    {
655        return $this->getRemoteTag('conflicts', []);
656    }
657
658    /**
659     * Get a list of DokuWiki versions this plugin is marked as compatible with
660     *
661     * @return string[][] date -> version
662     */
663    public function getCompatibleVersions()
664    {
665        return $this->getRemoteTag('compatible', []);
666    }
667
668    // endregion
669
670    // region Actions
671
672    /**
673     * Install or update the extension
674     *
675     * @throws Exception
676     */
677    public function installOrUpdate()
678    {
679        $installer = new Installer(true);
680        $installer->installExtension($this);
681    }
682
683    /**
684     * Uninstall the extension
685     * @throws Exception
686     */
687    public function uninstall()
688    {
689        $installer = new Installer(true);
690        $installer->uninstall($this);
691    }
692
693    /**
694     * Toggle the extension between enabled and disabled
695     * @return void
696     * @throws Exception
697     */
698    public function toggle()
699    {
700        if ($this->isEnabled()) {
701            $this->disable();
702        } else {
703            $this->enable();
704        }
705    }
706
707    /**
708     * Enable the extension
709     *
710     * @throws Exception
711     */
712    public function enable()
713    {
714        (new Installer())->enable($this);
715    }
716
717    /**
718     * Disable the extension
719     *
720     * @throws Exception
721     */
722    public function disable()
723    {
724        (new Installer())->disable($this);
725    }
726
727    // endregion
728
729    // region Meta Data Management
730
731    /**
732     * Access the Manager for this extension
733     *
734     * @return Manager
735     */
736    public function getManager()
737    {
738        if (!$this->manager instanceof Manager) {
739            $this->manager = new Manager($this);
740        }
741        return $this->manager;
742    }
743
744    /**
745     * Reads the info file of the extension if available and fills the localInfo array
746     */
747    protected function readLocalInfo()
748    {
749        if (!$this->getCurrentDir()) return;
750        $file = $this->currentDir . '/' . $this->type . '.info.txt';
751        if (!is_readable($file)) return;
752        $this->localInfo = confToHash($file, true);
753        $this->localInfo = array_filter($this->localInfo); // remove all falsy keys
754    }
755
756    /**
757     * Fetches the remote info from the repository
758     *
759     * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case
760     */
761    protected function loadRemoteInfo()
762    {
763        if ($this->remoteInfo) return;
764        $remote = Repository::getInstance();
765        try {
766            $this->remoteInfo = (array)$remote->getExtensionData($this->getId());
767        } catch (Exception $e) {
768            $this->remoteInfo = [];
769        }
770    }
771
772    /**
773     * Read information from either local or remote info
774     *
775     * Always prefers local info over remote info. Giving multiple keys is useful when the
776     * key has been renamed in the past or if local and remote keys might differ.
777     *
778     * @param string|string[] $tag one or multiple keys to check
779     * @param mixed $default
780     * @return mixed
781     */
782    protected function getTag($tag, $default = '')
783    {
784        foreach ((array)$tag as $t) {
785            if (isset($this->localInfo[$t])) return $this->localInfo[$t];
786        }
787
788        return $this->getRemoteTag($tag, $default);
789    }
790
791    /**
792     * Read information from remote info
793     *
794     * @param string|string[] $tag one or mutiple keys to check
795     * @param mixed $default
796     * @return mixed
797     */
798    protected function getRemoteTag($tag, $default = '')
799    {
800        $this->loadRemoteInfo();
801        foreach ((array)$tag as $t) {
802            if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t];
803        }
804        return $default;
805    }
806
807    // endregion
808
809    // region utilities
810
811    /**
812     * Convert an extension id to a type and base
813     *
814     * @param string $id
815     * @return array [type, base]
816     */
817    protected function idToTypeBase($id)
818    {
819        [$type, $base] = sexplode(':', $id, 2);
820        if ($base === null) {
821            $base = $type;
822            $type = self::TYPE_PLUGIN;
823        } elseif ($type === self::TYPE_TEMPLATE) {
824            $type = self::TYPE_TEMPLATE;
825        } else {
826            throw new RuntimeException('Invalid extension id: ' . $id);
827        }
828
829        return [$type, $base];
830    }
831
832    /**
833     * @return string
834     */
835    public function __toString()
836    {
837        return $this->getId();
838    }
839
840    // endregion
841}
842