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