xref: /dokuwiki/lib/plugins/extension/Extension.php (revision 160d3688fe2b70ebe88d464cba3417440127a155)
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    /**
410     * Has the download URL changed since the last download?
411     *
412     * @return bool
413     */
414    public function hasChangedURL()
415    {
416        $last = $this->getManager()->getDownloadUrl();
417        if(!$last) return false;
418        return $last !== $this->getDownloadURL();
419    }
420
421    /**
422     * Is an update available for this extension?
423     *
424     * @return bool
425     */
426    public function updateAvailable()
427    {
428        if($this->isBundled()) return false; // bundled extensions are never updated
429        $self = $this->getInstalledVersion();
430        $remote = $this->getLastUpdate();
431        return $self < $remote;
432    }
433
434    // endregion
435
436    // region Remote Info
437
438    /**
439     * Get the date of the last available update
440     *
441     * @return string yyyy-mm-dd
442     */
443    public function getLastUpdate()
444    {
445        return $this->getRemoteTag('lastupdate');
446    }
447
448    /**
449     * Get a list of tags this extension is tagged with at dokuwiki.org
450     *
451     * @return string[]
452     */
453    public function getTags()
454    {
455        return $this->getRemoteTag('tags', []);
456    }
457
458    /**
459     * Get the popularity of the extension
460     *
461     * This is a float between 0 and 1
462     *
463     * @return float
464     */
465    public function getPopularity()
466    {
467        return (float)$this->getRemoteTag('popularity', 0);
468    }
469
470    /**
471     * Get the text of the update message if there is any
472     *
473     * @return string
474     */
475    public function getUpdateMessage()
476    {
477        return $this->getRemoteTag('updatemessage');
478    }
479
480    /**
481     * Get the text of the security warning if there is any
482     *
483     * @return string
484     */
485    public function getSecurityWarning()
486    {
487        return $this->getRemoteTag('securitywarning');
488    }
489
490    /**
491     * Get the text of the security issue if there is any
492     *
493     * @return string
494     */
495    public function getSecurityIssue()
496    {
497        return $this->getRemoteTag('securityissue');
498    }
499
500    /**
501     * Get the URL of the screenshot of the extension if there is any
502     *
503     * @return string
504     */
505    public function getScreenshotURL()
506    {
507        return $this->getRemoteTag('screenshoturl');
508    }
509
510    /**
511     * Get the URL of the thumbnail of the extension if there is any
512     *
513     * @return string
514     */
515    public function getThumbnailURL()
516    {
517        return $this->getRemoteTag('thumbnailurl');
518    }
519
520    /**
521     * Get the download URL of the extension if there is any
522     *
523     * @return string
524     */
525    public function getDownloadURL()
526    {
527        return $this->getRemoteTag('downloadurl');
528    }
529
530    /**
531     * Get the bug tracker URL of the extension if there is any
532     *
533     * @return string
534     */
535    public function getBugtrackerURL()
536    {
537        return $this->getRemoteTag('bugtracker');
538    }
539
540    /**
541     * Get the URL of the source repository if there is any
542     *
543     * @return string
544     */
545    public function getSourcerepoURL()
546    {
547        return $this->getRemoteTag('sourcerepo');
548    }
549
550    /**
551     * Get the donation URL of the extension if there is any
552     *
553     * @return string
554     */
555    public function getDonationURL()
556    {
557        return $this->getRemoteTag('donationurl');
558    }
559
560    // endregion
561
562    // region Actions
563
564    /**
565     * Install or update the extension
566     *
567     * @throws Exception
568     */
569    public function installOrUpdate()
570    {
571        $installer = new Installer(true);
572        $installer->installExtension($this);
573    }
574
575    /**
576     * Uninstall the extension
577     * @throws Exception
578     */
579    public function uninstall()
580    {
581        $installer = new Installer(true);
582        $installer->uninstall($this);
583    }
584
585    /**
586     * Enable the extension
587     * @todo I'm unsure if this code should be here or part of Installer
588     * @throws Exception
589     */
590    public function enable()
591    {
592        if ($this->isTemplate()) throw new Exception('notimplemented');
593        if (!$this->isInstalled()) throw new Exception('error_notinstalled', [$this->getId()]);
594        if ($this->isEnabled()) throw new Exception('error_alreadyenabled', [$this->getId()]);
595
596        /* @var PluginController $plugin_controller */
597        global $plugin_controller;
598        if (!$plugin_controller->enable($this->base)) {
599            throw new Exception('pluginlistsaveerror');
600        }
601        Installer::purgeCache();
602    }
603
604    /**
605     * Disable the extension
606     * @todo I'm unsure if this code should be here or part of Installer
607     * @throws Exception
608     */
609    public function disable()
610    {
611        if ($this->isTemplate()) throw new Exception('notimplemented');
612        if (!$this->isInstalled()) throw new Exception('error_notinstalled', [$this->getId()]);
613        if (!$this->isEnabled()) throw new Exception('error_alreadydisabled', [$this->getId()]);
614        if ($this->isProtected()) throw new Exception('error_disable_protected', [$this->getId()]);
615
616        /* @var PluginController $plugin_controller */
617        global $plugin_controller;
618        if (!$plugin_controller->disable($this->base)) {
619            throw new Exception('pluginlistsaveerror');
620        }
621        Installer::purgeCache();
622    }
623
624    // endregion
625
626    // region Meta Data Management
627
628    /**
629     * Access the Manager for this extension
630     *
631     * @return Manager
632     */
633    public function getManager()
634    {
635        if ($this->manager === null) {
636            $this->manager = new Manager($this);
637        }
638        return $this->manager;
639    }
640
641    /**
642     * Reads the info file of the extension if available and fills the localInfo array
643     */
644    protected function readLocalInfo()
645    {
646        if (!$this->getCurrentDir()) return;
647        $file = $this->currentDir . '/' . $this->type . '.info.txt';
648        if (!is_readable($file)) return;
649        $this->localInfo = confToHash($file, true);
650        $this->localInfo = array_filter($this->localInfo); // remove all falsy keys
651    }
652
653    /**
654     * Fetches the remote info from the repository
655     *
656     * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case
657     */
658    protected function loadRemoteInfo()
659    {
660        if ($this->remoteInfo) return;
661        $remote = Repository::getInstance();
662        try {
663            $this->remoteInfo = (array)$remote->getExtensionData($this->getId());
664        } catch (Exception $e) {
665            $this->remoteInfo = [];
666        }
667    }
668
669    /**
670     * Read information from either local or remote info
671     *
672     * Always prefers local info over remote info. Giving multiple keys is useful when the
673     * key has been renamed in the past or if local and remote keys might differ.
674     *
675     * @param string|string[] $tag one or multiple keys to check
676     * @param mixed $default
677     * @return mixed
678     */
679    protected function getTag($tag, $default = '')
680    {
681        foreach ((array)$tag as $t) {
682            if (isset($this->localInfo[$t])) return $this->localInfo[$t];
683        }
684
685        return $this->getRemoteTag($tag, $default);
686    }
687
688    /**
689     * Read information from remote info
690     *
691     * @param string|string[] $tag one or mutiple keys to check
692     * @param mixed $default
693     * @return mixed
694     */
695    protected function getRemoteTag($tag, $default = '')
696    {
697        $this->loadRemoteInfo();
698        foreach ((array)$tag as $t) {
699            if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t];
700        }
701        return $default;
702    }
703
704    // endregion
705
706    // region utilities
707
708    /**
709     * Convert an extension id to a type and base
710     *
711     * @param string $id
712     * @return array [type, base]
713     */
714    protected function idToTypeBase($id)
715    {
716        [$type, $base] = sexplode(':', $id, 2);
717        if ($base === null) {
718            $base = $type;
719            $type = self::TYPE_PLUGIN;
720        } elseif ($type === self::TYPE_TEMPLATE) {
721            $type = self::TYPE_TEMPLATE;
722        } else {
723            throw new RuntimeException('Invalid extension id: ' . $id);
724        }
725
726        return [$type, $base];
727    }
728    /**
729     * @return string
730     */
731    public function __toString()
732    {
733        return $this->getId();
734    }
735
736    // endregion
737}
738