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