xref: /dokuwiki/lib/plugins/extension/Extension.php (revision b2a05b76de6c1d1e38212dff43776aaa41a22894)
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     * @return string The extension id (same as base but prefixed with "template:" for templates)
144     */
145    public function getId()
146    {
147        if ($this->type === self::TYPE_TEMPLATE) {
148            return self::TYPE_TEMPLATE . ':' . $this->base;
149        }
150        return $this->base;
151    }
152
153    /**
154     * Get the base name of this extension
155     *
156     * @return string
157     */
158    public function getBase()
159    {
160        return $this->base;
161    }
162
163    /**
164     * Get the type of the extension
165     *
166     * @return string "plugin"|"template"
167     */
168    public function getType()
169    {
170        return $this->type;
171    }
172
173    /**
174     * The current directory of the extension
175     *
176     * @return string|null
177     */
178    public function getCurrentDir()
179    {
180        // recheck that the current currentDir is still valid
181        if ($this->currentDir && !is_dir($this->currentDir)) {
182            $this->currentDir = '';
183        }
184
185        // if the extension is installed, then the currentDir is the install dir!
186        if (!$this->currentDir && $this->isInstalled()) {
187            $this->currentDir = $this->getInstallDir();
188        }
189
190        return $this->currentDir;
191    }
192
193    /**
194     * Get the directory where this extension should be installed in
195     *
196     * Note: this does not mean that the extension is actually installed there
197     *
198     * @return string
199     */
200    public function getInstallDir()
201    {
202        if ($this->isTemplate()) {
203            $dir = dirname(tpl_incdir()) . $this->base;
204        } else {
205            $dir = DOKU_PLUGIN . $this->base;
206        }
207
208        return fullpath($dir);
209    }
210
211
212    /**
213     * Get the display name of the extension
214     *
215     * @return string
216     */
217    public function getDisplayName()
218    {
219        return $this->getTag('name', PhpString::ucwords($this->getBase() . ' ' . $this->getType()));
220    }
221
222    /**
223     * Get the author name of the extension
224     *
225     * @return string Returns an empty string if the author info is missing
226     */
227    public function getAuthor()
228    {
229        return $this->getTag('author');
230    }
231
232    /**
233     * Get the email of the author of the extension if there is any
234     *
235     * @return string Returns an empty string if the email info is missing
236     */
237    public function getEmail()
238    {
239        // email is only in the local data
240        return $this->localInfo['email'] ?? '';
241    }
242
243    /**
244     * Get the email id, i.e. the md5sum of the email
245     *
246     * @return string Empty string if no email is available
247     */
248    public function getEmailID()
249    {
250        if (!empty($this->remoteInfo['emailid'])) return $this->remoteInfo['emailid'];
251        if (!empty($this->localInfo['email'])) return md5($this->localInfo['email']);
252        return '';
253    }
254
255    /**
256     * Get the description of the extension
257     *
258     * @return string Empty string if no description is available
259     */
260    public function getDescription()
261    {
262        return $this->getTag(['desc', 'description']);
263    }
264
265    /**
266     * Get the URL of the extension, usually a page on dokuwiki.org
267     *
268     * @return string
269     */
270    public function getURL()
271    {
272        return $this->getTag(
273            'url',
274            'https://www.dokuwiki.org/' .
275            ($this->isTemplate() ? 'template' : 'plugin') . ':' . $this->getBase()
276        );
277    }
278
279    /**
280     * Get the version of the extension that is actually installed
281     *
282     * Returns an empty string if the version is not available
283     *
284     * @return string
285     */
286    public function getInstalledVersion()
287    {
288        return $this->localInfo['date'] ?? '';
289    }
290
291    /**
292     * Get a list of extension ids this extension depends on
293     *
294     * @return string[]
295     */
296    public function getDependencyList()
297    {
298        return $this->getTag('depends', []);
299    }
300
301    /**
302     * Return the minimum PHP version required by the extension
303     *
304     * Empty if not set
305     *
306     * @return string
307     */
308    public function getMinimumPHPVersion()
309    {
310        return $this->getTag('phpmin', '');
311    }
312
313    /**
314     * Return the minimum PHP version supported by the extension
315     *
316     * @return string
317     */
318    public function getMaximumPHPVersion()
319    {
320        return $this->getTag('phpmax', '');
321    }
322
323    /**
324     * Is this extension a template?
325     *
326     * @return bool false if it is a plugin
327     */
328    public function isTemplate()
329    {
330        return $this->type === self::TYPE_TEMPLATE;
331    }
332
333    /**
334     * Is the extension installed locally?
335     *
336     * @return bool
337     */
338    public function isInstalled()
339    {
340        return is_dir($this->getInstallDir());
341    }
342
343    /**
344     * Is the extension under git control?
345     *
346     * @return bool
347     */
348    public function isGitControlled()
349    {
350        if (!$this->isInstalled()) return false;
351        return file_exists($this->getInstallDir() . '/.git');
352    }
353
354    /**
355     * If the extension is bundled
356     *
357     * @return bool If the extension is bundled
358     */
359    public function isBundled()
360    {
361        $this->loadRemoteInfo();
362        return $this->remoteInfo['bundled'] ?? in_array(
363            $this->getId(),
364            [
365                'authad',
366                'authldap',
367                'authpdo',
368                'authplain',
369                'acl',
370                'config',
371                'extension',
372                'info',
373                'popularity',
374                'revert',
375                'safefnrecode',
376                'styling',
377                'testing',
378                'usermanager',
379                'logviewer',
380                'template:dokuwiki'
381            ]
382        );
383    }
384
385    /**
386     * Is the extension protected against any modification (disable/uninstall)
387     *
388     * @return bool if the extension is protected
389     */
390    public function isProtected()
391    {
392        // never allow deinstalling the current auth plugin:
393        global $conf;
394        if ($this->getId() == $conf['authtype']) return true;
395
396        // FIXME disallow current template to be uninstalled
397
398        /** @var PluginController $plugin_controller */
399        global $plugin_controller;
400        $cascade = $plugin_controller->getCascade();
401        return ($cascade['protected'][$this->getId()] ?? false);
402    }
403
404    /**
405     * Is the extension installed in the correct directory?
406     *
407     * @return bool
408     */
409    public function isInWrongFolder()
410    {
411        return $this->getInstallDir() != $this->currentDir;
412    }
413
414    /**
415     * Is the extension enabled?
416     *
417     * @return bool
418     */
419    public function isEnabled()
420    {
421        global $conf;
422        if ($this->isTemplate()) {
423            return ($conf['template'] == $this->getBase());
424        }
425
426        /* @var PluginController $plugin_controller */
427        global $plugin_controller;
428        return $plugin_controller->isEnabled($this->base);
429    }
430
431    /**
432     * Has the download URL changed since the last download?
433     *
434     * @return bool
435     */
436    public function hasChangedURL()
437    {
438        $last = $this->getManager()->getDownloadUrl();
439        if(!$last) return false;
440        return $last !== $this->getDownloadURL();
441    }
442
443    /**
444     * Is an update available for this extension?
445     *
446     * @return bool
447     */
448    public function updateAvailable()
449    {
450        if($this->isBundled()) return false; // bundled extensions are never updated
451        $self = $this->getInstalledVersion();
452        $remote = $this->getLastUpdate();
453        return $self < $remote;
454    }
455
456    // endregion
457
458    // region Remote Info
459
460    /**
461     * Get the date of the last available update
462     *
463     * @return string yyyy-mm-dd
464     */
465    public function getLastUpdate()
466    {
467        return $this->getRemoteTag('lastupdate');
468    }
469
470    /**
471     * Get a list of tags this extension is tagged with at dokuwiki.org
472     *
473     * @return string[]
474     */
475    public function getTags()
476    {
477        return $this->getRemoteTag('tags', []);
478    }
479
480    /**
481     * Get the popularity of the extension
482     *
483     * This is a float between 0 and 1
484     *
485     * @return float
486     */
487    public function getPopularity()
488    {
489        return (float)$this->getRemoteTag('popularity', 0);
490    }
491
492    /**
493     * Get the text of the update message if there is any
494     *
495     * @return string
496     */
497    public function getUpdateMessage()
498    {
499        return $this->getRemoteTag('updatemessage');
500    }
501
502    /**
503     * Get the text of the security warning if there is any
504     *
505     * @return string
506     */
507    public function getSecurityWarning()
508    {
509        return $this->getRemoteTag('securitywarning');
510    }
511
512    /**
513     * Get the text of the security issue if there is any
514     *
515     * @return string
516     */
517    public function getSecurityIssue()
518    {
519        return $this->getRemoteTag('securityissue');
520    }
521
522    /**
523     * Get the URL of the screenshot of the extension if there is any
524     *
525     * @return string
526     */
527    public function getScreenshotURL()
528    {
529        return $this->getRemoteTag('screenshoturl');
530    }
531
532    /**
533     * Get the URL of the thumbnail of the extension if there is any
534     *
535     * @return string
536     */
537    public function getThumbnailURL()
538    {
539        return $this->getRemoteTag('thumbnailurl');
540    }
541
542    /**
543     * Get the download URL of the extension if there is any
544     *
545     * @return string
546     */
547    public function getDownloadURL()
548    {
549        return $this->getRemoteTag('downloadurl');
550    }
551
552    /**
553     * Get the bug tracker URL of the extension if there is any
554     *
555     * @return string
556     */
557    public function getBugtrackerURL()
558    {
559        return $this->getRemoteTag('bugtracker');
560    }
561
562    /**
563     * Get the URL of the source repository if there is any
564     *
565     * @return string
566     */
567    public function getSourcerepoURL()
568    {
569        return $this->getRemoteTag('sourcerepo');
570    }
571
572    /**
573     * Get the donation URL of the extension if there is any
574     *
575     * @return string
576     */
577    public function getDonationURL()
578    {
579        return $this->getRemoteTag('donationurl');
580    }
581
582    // endregion
583
584    // region Actions
585
586    /**
587     * Install or update the extension
588     *
589     * @throws Exception
590     */
591    public function installOrUpdate()
592    {
593        $installer = new Installer(true);
594        $installer->installExtension($this);
595    }
596
597    /**
598     * Uninstall the extension
599     * @throws Exception
600     */
601    public function uninstall()
602    {
603        $installer = new Installer(true);
604        $installer->uninstall($this);
605    }
606
607    /**
608     * Enable the extension
609     * @todo I'm unsure if this code should be here or part of Installer
610     * @throws Exception
611     */
612    public function enable()
613    {
614        if ($this->isTemplate()) throw new Exception('notimplemented');
615        if (!$this->isInstalled()) throw new Exception('error_notinstalled', [$this->getId()]);
616        if ($this->isEnabled()) throw new Exception('error_alreadyenabled', [$this->getId()]);
617
618        /* @var PluginController $plugin_controller */
619        global $plugin_controller;
620        if (!$plugin_controller->enable($this->base)) {
621            throw new Exception('pluginlistsaveerror');
622        }
623        Installer::purgeCache();
624    }
625
626    /**
627     * Disable the extension
628     * @todo I'm unsure if this code should be here or part of Installer
629     * @throws Exception
630     */
631    public function disable()
632    {
633        if ($this->isTemplate()) throw new Exception('notimplemented');
634        if (!$this->isInstalled()) throw new Exception('error_notinstalled', [$this->getId()]);
635        if (!$this->isEnabled()) throw new Exception('error_alreadydisabled', [$this->getId()]);
636        if ($this->isProtected()) throw new Exception('error_disable_protected', [$this->getId()]);
637
638        /* @var PluginController $plugin_controller */
639        global $plugin_controller;
640        if (!$plugin_controller->disable($this->base)) {
641            throw new Exception('pluginlistsaveerror');
642        }
643        Installer::purgeCache();
644    }
645
646    // endregion
647
648    // region Meta Data Management
649
650    /**
651     * Access the Manager for this extension
652     *
653     * @return Manager
654     */
655    public function getManager()
656    {
657        if ($this->manager === null) {
658            $this->manager = new Manager($this);
659        }
660        return $this->manager;
661    }
662
663    /**
664     * Reads the info file of the extension if available and fills the localInfo array
665     */
666    protected function readLocalInfo()
667    {
668        if (!$this->getCurrentDir()) return;
669        $file = $this->currentDir . '/' . $this->type . '.info.txt';
670        if (!is_readable($file)) return;
671        $this->localInfo = confToHash($file, true);
672        $this->localInfo = array_filter($this->localInfo); // remove all falsy keys
673    }
674
675    /**
676     * Fetches the remote info from the repository
677     *
678     * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case
679     */
680    protected function loadRemoteInfo()
681    {
682        if ($this->remoteInfo) return;
683        $remote = Repository::getInstance();
684        try {
685            $this->remoteInfo = (array)$remote->getExtensionData($this->getId());
686        } catch (Exception $e) {
687            $this->remoteInfo = [];
688        }
689    }
690
691    /**
692     * Read information from either local or remote info
693     *
694     * Always prefers local info over remote info. Giving multiple keys is useful when the
695     * key has been renamed in the past or if local and remote keys might differ.
696     *
697     * @param string|string[] $tag one or multiple keys to check
698     * @param mixed $default
699     * @return mixed
700     */
701    protected function getTag($tag, $default = '')
702    {
703        foreach ((array)$tag as $t) {
704            if (isset($this->localInfo[$t])) return $this->localInfo[$t];
705        }
706
707        return $this->getRemoteTag($tag, $default);
708    }
709
710    /**
711     * Read information from remote info
712     *
713     * @param string|string[] $tag one or mutiple keys to check
714     * @param mixed $default
715     * @return mixed
716     */
717    protected function getRemoteTag($tag, $default = '')
718    {
719        $this->loadRemoteInfo();
720        foreach ((array)$tag as $t) {
721            if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t];
722        }
723        return $default;
724    }
725
726    // endregion
727
728    // region utilities
729
730    /**
731     * Convert an extension id to a type and base
732     *
733     * @param string $id
734     * @return array [type, base]
735     */
736    protected function idToTypeBase($id)
737    {
738        [$type, $base] = sexplode(':', $id, 2);
739        if ($base === null) {
740            $base = $type;
741            $type = self::TYPE_PLUGIN;
742        } elseif ($type === self::TYPE_TEMPLATE) {
743            $type = self::TYPE_TEMPLATE;
744        } else {
745            throw new RuntimeException('Invalid extension id: ' . $id);
746        }
747
748        return [$type, $base];
749    }
750    /**
751     * @return string
752     */
753    public function __toString()
754    {
755        return $this->getId();
756    }
757
758    // endregion
759}
760