xref: /dokuwiki/lib/plugins/extension/Extension.php (revision 25d28a0105a16bf32f2784e80b3cf268074c8c8a)
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     * Is this extension a template?
303     *
304     * @return bool false if it is a plugin
305     */
306    public function isTemplate()
307    {
308        return $this->type === self::TYPE_TEMPLATE;
309    }
310
311    /**
312     * Is the extension installed locally?
313     *
314     * @return bool
315     */
316    public function isInstalled()
317    {
318        return is_dir($this->getInstallDir());
319    }
320
321    /**
322     * Is the extension under git control?
323     *
324     * @return bool
325     */
326    public function isGitControlled()
327    {
328        if (!$this->isInstalled()) return false;
329        return file_exists($this->getInstallDir() . '/.git');
330    }
331
332    /**
333     * If the extension is bundled
334     *
335     * @return bool If the extension is bundled
336     */
337    public function isBundled()
338    {
339        $this->loadRemoteInfo();
340        return $this->remoteInfo['bundled'] ?? in_array(
341            $this->getId(),
342            [
343                'authad',
344                'authldap',
345                'authpdo',
346                'authplain',
347                'acl',
348                'config',
349                'extension',
350                'info',
351                'popularity',
352                'revert',
353                'safefnrecode',
354                'styling',
355                'testing',
356                'usermanager',
357                'logviewer',
358                'template:dokuwiki'
359            ]
360        );
361    }
362
363    /**
364     * Is the extension protected against any modification (disable/uninstall)
365     *
366     * @return bool if the extension is protected
367     */
368    public function isProtected()
369    {
370        // never allow deinstalling the current auth plugin:
371        global $conf;
372        if ($this->getId() == $conf['authtype']) return true;
373
374        // FIXME disallow current template to be uninstalled
375
376        /** @var PluginController $plugin_controller */
377        global $plugin_controller;
378        $cascade = $plugin_controller->getCascade();
379        return ($cascade['protected'][$this->getId()] ?? false);
380    }
381
382    /**
383     * Is the extension installed in the correct directory?
384     *
385     * @return bool
386     */
387    public function isInWrongFolder()
388    {
389        return $this->getInstallDir() != $this->currentDir;
390    }
391
392    /**
393     * Is the extension enabled?
394     *
395     * @return bool
396     */
397    public function isEnabled()
398    {
399        global $conf;
400        if ($this->isTemplate()) {
401            return ($conf['template'] == $this->getBase());
402        }
403
404        /* @var PluginController $plugin_controller */
405        global $plugin_controller;
406        return $plugin_controller->isEnabled($this->base);
407    }
408
409    // endregion
410
411    // region Remote Info
412
413    /**
414     * Get the date of the last available update
415     *
416     * @return string yyyy-mm-dd
417     */
418    public function getLastUpdate()
419    {
420        return $this->getRemoteTag('lastupdate');
421    }
422
423    /**
424     * Get a list of tags this extension is tagged with at dokuwiki.org
425     *
426     * @return string[]
427     */
428    public function getTags()
429    {
430        return $this->getRemoteTag('tags', []);
431    }
432
433    /**
434     * Get the popularity of the extension
435     *
436     * This is a float between 0 and 1
437     *
438     * @return float
439     */
440    public function getPopularity()
441    {
442        return (float)$this->getRemoteTag('popularity', 0);
443    }
444
445    /**
446     * Get the text of the update message if there is any
447     *
448     * @return string
449     */
450    public function getUpdateMessage()
451    {
452        return $this->getRemoteTag('updatemessage');
453    }
454
455    /**
456     * Get the text of the security warning if there is any
457     *
458     * @return string
459     */
460    public function getSecurityWarning()
461    {
462        return $this->getRemoteTag('securitywarning');
463    }
464
465    /**
466     * Get the text of the security issue if there is any
467     *
468     * @return string
469     */
470    public function getSecurityIssue()
471    {
472        return $this->getRemoteTag('securityissue');
473    }
474
475    /**
476     * Get the URL of the screenshot of the extension if there is any
477     *
478     * @return string
479     */
480    public function getScreenshotURL()
481    {
482        return $this->getRemoteTag('screenshoturl');
483    }
484
485    /**
486     * Get the URL of the thumbnail of the extension if there is any
487     *
488     * @return string
489     */
490    public function getThumbnailURL()
491    {
492        return $this->getRemoteTag('thumbnailurl');
493    }
494
495    /**
496     * Get the download URL of the extension if there is any
497     *
498     * @return string
499     */
500    public function getDownloadURL()
501    {
502        return $this->getRemoteTag('downloadurl');
503    }
504
505    /**
506     * Get the bug tracker URL of the extension if there is any
507     *
508     * @return string
509     */
510    public function getBugtrackerURL()
511    {
512        return $this->getRemoteTag('bugtracker');
513    }
514
515    /**
516     * Get the URL of the source repository if there is any
517     *
518     * @return string
519     */
520    public function getSourcerepoURL()
521    {
522        return $this->getRemoteTag('sourcerepo');
523    }
524
525    /**
526     * Get the donation URL of the extension if there is any
527     *
528     * @return string
529     */
530    public function getDonationURL()
531    {
532        return $this->getRemoteTag('donationurl');
533    }
534
535    // endregion
536
537    // region Actions
538
539    /**
540     * Install or update the extension
541     *
542     * @throws Exception
543     */
544    public function installOrUpdate()
545    {
546        $installer = new Installer(true);
547        $installer->installFromUrl(
548            $this->getURL(),
549            $this->getBase(),
550        );
551    }
552
553    /**
554     * Uninstall the extension
555     * @throws Exception
556     */
557    public function uninstall()
558    {
559        $installer = new Installer(true);
560        $installer->uninstall($this);
561    }
562
563    /**
564     * Enable the extension
565     * @todo I'm unsure if this code should be here or part of Installer
566     * @throws Exception
567     */
568    public function enable()
569    {
570        if ($this->isTemplate()) throw new Exception('notimplemented');
571        if (!$this->isInstalled()) throw new Exception('notinstalled');
572        if ($this->isEnabled()) throw new Exception('alreadyenabled');
573
574        /* @var PluginController $plugin_controller */
575        global $plugin_controller;
576        if (!$plugin_controller->enable($this->base)) {
577            throw new Exception('pluginlistsaveerror');
578        }
579        Installer::purgeCache();
580    }
581
582    /**
583     * Disable the extension
584     * @todo I'm unsure if this code should be here or part of Installer
585     * @throws Exception
586     */
587    public function disable()
588    {
589        if ($this->isTemplate()) throw new Exception('notimplemented');
590        if (!$this->isInstalled()) throw new Exception('notinstalled');
591        if (!$this->isEnabled()) throw new Exception('alreadydisabled');
592        if ($this->isProtected()) throw new Exception('error_disable_protected');
593
594        /* @var PluginController $plugin_controller */
595        global $plugin_controller;
596        if (!$plugin_controller->disable($this->base)) {
597            throw new Exception('pluginlistsaveerror');
598        }
599        Installer::purgeCache();
600    }
601
602    // endregion
603
604    // region Meta Data Management
605
606    /**
607     * Access the Manager for this extension
608     *
609     * @return Manager
610     */
611    public function getManager()
612    {
613        if ($this->manager === null) {
614            $this->manager = new Manager($this);
615        }
616        return $this->manager;
617    }
618
619    /**
620     * Reads the info file of the extension if available and fills the localInfo array
621     */
622    protected function readLocalInfo()
623    {
624        if (!$this->getCurrentDir()) return;
625        $file = $this->currentDir . '/' . $this->type . '.info.txt';
626        if (!is_readable($file)) return;
627        $this->localInfo = confToHash($file, true);
628        $this->localInfo = array_filter($this->localInfo); // remove all falsy keys
629    }
630
631    /**
632     * Fetches the remote info from the repository
633     *
634     * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case
635     */
636    protected function loadRemoteInfo()
637    {
638        if ($this->remoteInfo) return;
639        $remote = Repository::getInstance();
640        try {
641            $this->remoteInfo = (array)$remote->getExtensionData($this->getId());
642        } catch (Exception $e) {
643            $this->remoteInfo = [];
644        }
645    }
646
647    /**
648     * Read information from either local or remote info
649     *
650     * Always prefers local info over remote info. Giving multiple keys is useful when the
651     * key has been renamed in the past or if local and remote keys might differ.
652     *
653     * @param string|string[] $tag one or multiple keys to check
654     * @param mixed $default
655     * @return mixed
656     */
657    protected function getTag($tag, $default = '')
658    {
659        foreach ((array)$tag as $t) {
660            if (isset($this->localInfo[$t])) return $this->localInfo[$t];
661        }
662
663        return $this->getRemoteTag($tag, $default);
664    }
665
666    /**
667     * Read information from remote info
668     *
669     * @param string|string[] $tag one or mutiple keys to check
670     * @param mixed $default
671     * @return mixed
672     */
673    protected function getRemoteTag($tag, $default = '')
674    {
675        $this->loadRemoteInfo();
676        foreach ((array)$tag as $t) {
677            if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t];
678        }
679        return $default;
680    }
681
682    // endregion
683
684    // region utilities
685
686    /**
687     * Convert an extension id to a type and base
688     *
689     * @param string $id
690     * @return array [type, base]
691     */
692    protected function idToTypeBase($id)
693    {
694        [$type, $base] = sexplode(':', $id, 2);
695        if ($base === null) {
696            $base = $type;
697            $type = self::TYPE_PLUGIN;
698        } elseif ($type === self::TYPE_TEMPLATE) {
699            $type = self::TYPE_TEMPLATE;
700        } else {
701            throw new RuntimeException('Invalid extension id: ' . $id);
702        }
703
704        return [$type, $base];
705    }
706    /**
707     * @return string
708     */
709    public function __toString()
710    {
711        return $this->getId();
712    }
713
714    // endregion
715}
716