1<?php
2
3namespace dokuwiki\plugin\extension;
4
5use dokuwiki\Extension\PluginController;
6use dokuwiki\Utf8\PhpString;
7use RuntimeException;
8
9class Extension
10{
11    public const TYPE_PLUGIN = 'plugin';
12    public const TYPE_TEMPLATE = 'template';
13
14    /** @var string[] The types the API uses for plugin components */
15    public const COMPONENT_TYPES = [
16        1 => 'Syntax',
17        2 => 'Admin',
18        4 => 'Action',
19        8 => 'Render',
20        16 => 'Helper',
21        32 => 'Template',
22        64 => 'Remote',
23        128 => 'Auth',
24        256 => 'CLI',
25        512 => 'CSS/JS-only',
26    ];
27
28    /** @var string "plugin"|"template" */
29    protected string $type = self::TYPE_PLUGIN;
30
31    /** @var string The base name of this extension */
32    protected string $base;
33
34    /** @var string The current location of this extension */
35    protected string $currentDir = '';
36
37    /** @var array The local info array of the extension */
38    protected array $localInfo = [];
39
40    /** @var array The remote info array of the extension */
41    protected array $remoteInfo = [];
42
43    /** @var Manager|null The manager for this extension */
44    protected ?Manager $manager = null;
45
46    // region Constructors
47
48    /**
49     * The main constructor is private to force the use of the factory methods
50     */
51    protected function __construct()
52    {
53    }
54
55    /**
56     * Initializes an extension from an id
57     *
58     * @param string $id The id of the extension
59     * @return Extension
60     */
61    public static function createFromId($id)
62    {
63        $extension = new self();
64        $extension->initFromId($id);
65        return $extension;
66    }
67
68    protected function initFromId($id)
69    {
70        [$type, $base] = $this->idToTypeBase($id);
71        $this->type = $type;
72        $this->base = $base;
73        $this->readLocalInfo();
74    }
75
76    /**
77     * Initializes an extension from a directory
78     *
79     * The given directory might be the one where the extension has already been installed to
80     * or it might be the extracted source in some temporary directory.
81     *
82     * @param string $dir Where the extension code is currently located
83     * @param string|null $type TYPE_PLUGIN|TYPE_TEMPLATE, null for auto-detection
84     * @param string $base The base name of the extension, null for auto-detection
85     * @return Extension
86     */
87    public static function createFromDirectory($dir, $type = null, $base = null)
88    {
89        $extension = new self();
90        $extension->initFromDirectory($dir, $type, $base);
91        return $extension;
92    }
93
94    protected function initFromDirectory($dir, $type = null, $base = null)
95    {
96        if (!is_dir($dir)) throw new RuntimeException('Directory not found: ' . $dir);
97        $this->currentDir = fullpath($dir);
98
99        if ($type === null || $type === self::TYPE_TEMPLATE) {
100            if (
101                file_exists($dir . '/template.info.txt') ||
102                file_exists($dir . '/style.ini') ||
103                file_exists($dir . '/main.php') ||
104                file_exists($dir . '/detail.php') ||
105                file_exists($dir . '/mediamanager.php')
106            ) {
107                $this->type = self::TYPE_TEMPLATE;
108            }
109        } else {
110            $this->type = self::TYPE_PLUGIN;
111        }
112
113        $this->readLocalInfo();
114
115        if ($base !== null) {
116            $this->base = $base;
117        } elseif (isset($this->localInfo['base'])) {
118            $this->base = $this->localInfo['base'];
119        } else {
120            $this->base = basename($dir);
121        }
122    }
123
124    /**
125     * Initializes an extension from remote data
126     *
127     * @param array $data The data as returned by the repository api
128     * @return Extension
129     */
130    public static function createFromRemoteData($data)
131    {
132        $extension = new self();
133        $extension->initFromRemoteData($data);
134        return $extension;
135    }
136
137    protected function initFromRemoteData($data)
138    {
139        if (!isset($data['plugin'])) throw new RuntimeException('Invalid remote data');
140
141        [$type, $base] = $this->idToTypeBase($data['plugin']);
142        $this->remoteInfo = $data;
143        $this->type = $type;
144        $this->base = $base;
145
146        if ($this->isInstalled()) {
147            $this->currentDir = $this->getInstallDir();
148            $this->readLocalInfo();
149        }
150    }
151
152    // endregion
153
154    // region Getters
155
156    /**
157     * @param bool $wrap If true, the id is wrapped in backticks
158     * @return string The extension id (same as base but prefixed with "template:" for templates)
159     */
160    public function getId($wrap = false)
161    {
162        if ($this->type === self::TYPE_TEMPLATE) {
163            $id = self::TYPE_TEMPLATE . ':' . $this->base;
164        } else {
165            $id = $this->base;
166        }
167        if ($wrap) $id = "`$id`";
168        return $id;
169    }
170
171    /**
172     * Get the base name of this extension
173     *
174     * @return string
175     */
176    public function getBase()
177    {
178        return $this->base;
179    }
180
181    /**
182     * Get the type of the extension
183     *
184     * @return string "plugin"|"template"
185     */
186    public function getType()
187    {
188        return $this->type;
189    }
190
191    /**
192     * The current directory of the extension
193     *
194     * @return string|null
195     */
196    public function getCurrentDir()
197    {
198        // recheck that the current currentDir is still valid
199        if ($this->currentDir && !is_dir($this->currentDir)) {
200            $this->currentDir = '';
201        }
202
203        // if the extension is installed, then the currentDir is the install dir!
204        if (!$this->currentDir && $this->isInstalled()) {
205            $this->currentDir = $this->getInstallDir();
206        }
207
208        return $this->currentDir;
209    }
210
211    /**
212     * Get the directory where this extension should be installed in
213     *
214     * Note: this does not mean that the extension is actually installed there
215     *
216     * @return string
217     */
218    public function getInstallDir()
219    {
220        if ($this->isTemplate()) {
221            $dir = dirname(tpl_incdir()) . '/' . $this->base;
222        } else {
223            $dir = DOKU_PLUGIN . $this->base;
224        }
225
226        return fullpath($dir);
227    }
228
229
230    /**
231     * Get the display name of the extension
232     *
233     * @return string
234     */
235    public function getDisplayName()
236    {
237        return $this->getTag('name', PhpString::ucwords($this->getBase() . ' ' . $this->getType()));
238    }
239
240    /**
241     * Get the author name of the extension
242     *
243     * @return string Returns an empty string if the author info is missing
244     */
245    public function getAuthor()
246    {
247        return $this->getTag('author');
248    }
249
250    /**
251     * Get the email of the author of the extension if there is any
252     *
253     * @return string Returns an empty string if the email info is missing
254     */
255    public function getEmail()
256    {
257        // email is only in the local data
258        return $this->localInfo['email'] ?? '';
259    }
260
261    /**
262     * Get the email id, i.e. the md5sum of the email
263     *
264     * @return string Empty string if no email is available
265     */
266    public function getEmailID()
267    {
268        if (!empty($this->remoteInfo['emailid'])) return $this->remoteInfo['emailid'];
269        if (!empty($this->localInfo['email'])) return md5($this->localInfo['email']);
270        return '';
271    }
272
273    /**
274     * Get the description of the extension
275     *
276     * @return string Empty string if no description is available
277     */
278    public function getDescription()
279    {
280        return $this->getTag(['desc', 'description']);
281    }
282
283    /**
284     * Get the URL of the extension, usually a page on dokuwiki.org
285     *
286     * @return string
287     */
288    public function getURL()
289    {
290        return $this->getTag(
291            'url',
292            'https://www.dokuwiki.org/' .
293            ($this->isTemplate() ? 'template' : 'plugin') . ':' . $this->getBase()
294        );
295    }
296
297    /**
298     * Get the version of the extension that is actually installed
299     *
300     * Returns an empty string if the version is not available
301     *
302     * @return string
303     */
304    public function getInstalledVersion()
305    {
306        return $this->localInfo['date'] ?? '';
307    }
308
309    /**
310     * Get the types of components this extension provides
311     *
312     * @return array int -> type
313     */
314    public function getComponentTypes()
315    {
316        // for installed extensions we can check the files
317        if ($this->isInstalled()) {
318            if ($this->isTemplate()) {
319                return ['Template'];
320            } else {
321                $types = [];
322                foreach (self::COMPONENT_TYPES as $type) {
323                    $check = strtolower($type);
324                    if (
325                        file_exists($this->getInstallDir() . '/' . $check . '.php') ||
326                        is_dir($this->getInstallDir() . '/' . $check)
327                    ) {
328                        $types[] = $type;
329                    }
330                }
331                return $types;
332            }
333        }
334        // still, here? use the remote info
335        return $this->getTag('types', []);
336    }
337
338    /**
339     * Get a list of extension ids this extension depends on
340     *
341     * @return string[]
342     */
343    public function getDependencyList()
344    {
345        return $this->getTag('depends', []);
346    }
347
348    /**
349     * Get a list of extensions that are currently installed, enabled and depend on this extension
350     *
351     * @return Extension[]
352     */
353    public function getDependants()
354    {
355        $local = new Local();
356        $extensions = $local->getExtensions();
357        $dependants = [];
358        foreach ($extensions as $extension) {
359            if (
360                in_array($this->getId(), $extension->getDependencyList()) &&
361                $extension->isEnabled()
362            ) {
363                $dependants[$extension->getId()] = $extension;
364            }
365        }
366        return $dependants;
367    }
368
369    /**
370     * Return the minimum PHP version required by the extension
371     *
372     * Empty if not set
373     *
374     * @return string
375     */
376    public function getMinimumPHPVersion()
377    {
378        return $this->getTag('phpmin', '');
379    }
380
381    /**
382     * Return the minimum PHP version supported by the extension
383     *
384     * @return string
385     */
386    public function getMaximumPHPVersion()
387    {
388        return $this->getTag('phpmax', '');
389    }
390
391    /**
392     * Is this extension a template?
393     *
394     * @return bool false if it is a plugin
395     */
396    public function isTemplate()
397    {
398        return $this->type === self::TYPE_TEMPLATE;
399    }
400
401    /**
402     * Is the extension installed locally?
403     *
404     * @return bool
405     */
406    public function isInstalled()
407    {
408        return is_dir($this->getInstallDir());
409    }
410
411    /**
412     * Is the extension under git control?
413     *
414     * @return bool
415     */
416    public function isGitControlled()
417    {
418        if (!$this->isInstalled()) return false;
419        return file_exists($this->getInstallDir() . '/.git');
420    }
421
422    /**
423     * If the extension is bundled
424     *
425     * @return bool If the extension is bundled
426     */
427    public function isBundled()
428    {
429        $this->loadRemoteInfo();
430        return $this->remoteInfo['bundled'] ?? in_array(
431            $this->getId(),
432            [
433                'authad',
434                'authldap',
435                'authpdo',
436                'authplain',
437                'acl',
438                'config',
439                'extension',
440                'info',
441                'popularity',
442                'revert',
443                'safefnrecode',
444                'styling',
445                'testing',
446                'usermanager',
447                'logviewer',
448                'template:dokuwiki'
449            ]
450        );
451    }
452
453    /**
454     * Is the extension protected against any modification (disable/uninstall)
455     *
456     * @return bool if the extension is protected
457     */
458    public function isProtected()
459    {
460        // never allow deinstalling the current auth plugin:
461        global $conf;
462        if ($this->getId() == $conf['authtype']) return true;
463
464        // disallow current template to be uninstalled
465        if ($this->isTemplate() && ($this->getBase() === $conf['template'])) return true;
466
467        /** @var PluginController $plugin_controller */
468        global $plugin_controller;
469        $cascade = $plugin_controller->getCascade();
470        return ($cascade['protected'][$this->getId()] ?? false);
471    }
472
473    /**
474     * Is the extension installed in the correct directory?
475     *
476     * @return bool
477     */
478    public function isInWrongFolder()
479    {
480        if (!$this->isInstalled()) return false;
481        return $this->getInstallDir() != $this->currentDir;
482    }
483
484    /**
485     * Is the extension enabled?
486     *
487     * @return bool
488     */
489    public function isEnabled()
490    {
491        global $conf;
492        if ($this->isTemplate()) {
493            return ($conf['template'] == $this->getBase());
494        }
495
496        /* @var PluginController $plugin_controller */
497        global $plugin_controller;
498        return $plugin_controller->isEnabled($this->base);
499    }
500
501    /**
502     * Has the download URL changed since the last download?
503     *
504     * @return bool
505     */
506    public function hasChangedURL()
507    {
508        $last = $this->getManager()->getDownloadURL();
509        if (!$last) return false;
510        return $last !== $this->getDownloadURL();
511    }
512
513    /**
514     * Is an update available for this extension?
515     *
516     * @return bool
517     */
518    public function isUpdateAvailable()
519    {
520        if ($this->isBundled()) return false; // bundled extensions are never updated
521        $self = $this->getInstalledVersion();
522        $remote = $this->getLastUpdate();
523        return $self < $remote;
524    }
525
526    // endregion
527
528    // region Remote Info
529
530    /**
531     * Get the date of the last available update
532     *
533     * @return string yyyy-mm-dd
534     */
535    public function getLastUpdate()
536    {
537        return $this->getRemoteTag('lastupdate');
538    }
539
540    /**
541     * Get a list of tags this extension is tagged with at dokuwiki.org
542     *
543     * @return string[]
544     */
545    public function getTags()
546    {
547        return $this->getRemoteTag('tags', []);
548    }
549
550    /**
551     * Get the popularity of the extension
552     *
553     * This is a float between 0 and 1
554     *
555     * @return float
556     */
557    public function getPopularity()
558    {
559        return (float)$this->getRemoteTag('popularity', 0);
560    }
561
562    /**
563     * Get the text of the update message if there is any
564     *
565     * @return string
566     */
567    public function getUpdateMessage()
568    {
569        return $this->getRemoteTag('updatemessage');
570    }
571
572    /**
573     * Get the text of the security warning if there is any
574     *
575     * @return string
576     */
577    public function getSecurityWarning()
578    {
579        return $this->getRemoteTag('securitywarning');
580    }
581
582    /**
583     * Get the text of the security issue if there is any
584     *
585     * @return string
586     */
587    public function getSecurityIssue()
588    {
589        return $this->getRemoteTag('securityissue');
590    }
591
592    /**
593     * Get the URL of the screenshot of the extension if there is any
594     *
595     * @return string
596     */
597    public function getScreenshotURL()
598    {
599        return $this->getRemoteTag('screenshoturl');
600    }
601
602    /**
603     * Get the URL of the thumbnail of the extension if there is any
604     *
605     * @return string
606     */
607    public function getThumbnailURL()
608    {
609        return $this->getRemoteTag('thumbnailurl');
610    }
611
612    /**
613     * Get the download URL of the extension if there is any
614     *
615     * @return string
616     */
617    public function getDownloadURL()
618    {
619        return $this->getRemoteTag('downloadurl');
620    }
621
622    /**
623     * Get the bug tracker URL of the extension if there is any
624     *
625     * @return string
626     */
627    public function getBugtrackerURL()
628    {
629        return $this->getRemoteTag('bugtracker');
630    }
631
632    /**
633     * Get the URL of the source repository if there is any
634     *
635     * @return string
636     */
637    public function getSourcerepoURL()
638    {
639        return $this->getRemoteTag('sourcerepo');
640    }
641
642    /**
643     * Get the donation URL of the extension if there is any
644     *
645     * @return string
646     */
647    public function getDonationURL()
648    {
649        return $this->getRemoteTag('donationurl');
650    }
651
652    /**
653     * Get a list of extensions that are similar to this one
654     *
655     * @return string[]
656     */
657    public function getSimilarList()
658    {
659        return $this->getRemoteTag('similar', []);
660    }
661
662    /**
663     * Get a list of extensions that are marked as conflicting with this one
664     *
665     * @return string[]
666     */
667    public function getConflictList()
668    {
669        return $this->getRemoteTag('conflicts', []);
670    }
671
672    /**
673     * Get a list of DokuWiki versions this plugin is marked as compatible with
674     *
675     * @return string[][] date -> version
676     */
677    public function getCompatibleVersions()
678    {
679        return $this->getRemoteTag('compatible', []);
680    }
681
682    // endregion
683
684    // region Actions
685
686    /**
687     * Install or update the extension
688     *
689     * @throws Exception
690     */
691    public function installOrUpdate()
692    {
693        $installer = new Installer(true);
694        $installer->installExtension($this);
695    }
696
697    /**
698     * Uninstall the extension
699     * @throws Exception
700     */
701    public function uninstall()
702    {
703        $installer = new Installer(true);
704        $installer->uninstall($this);
705    }
706
707    /**
708     * Toggle the extension between enabled and disabled
709     * @return void
710     * @throws Exception
711     */
712    public function toggle()
713    {
714        if ($this->isEnabled()) {
715            $this->disable();
716        } else {
717            $this->enable();
718        }
719    }
720
721    /**
722     * Enable the extension
723     *
724     * @throws Exception
725     */
726    public function enable()
727    {
728        (new Installer())->enable($this);
729    }
730
731    /**
732     * Disable the extension
733     *
734     * @throws Exception
735     */
736    public function disable()
737    {
738        (new Installer())->disable($this);
739    }
740
741    // endregion
742
743    // region Meta Data Management
744
745    /**
746     * Access the Manager for this extension
747     *
748     * @return Manager
749     */
750    public function getManager()
751    {
752        if (!$this->manager instanceof Manager) {
753            $this->manager = new Manager($this);
754        }
755        return $this->manager;
756    }
757
758    /**
759     * Reads the info file of the extension if available and fills the localInfo array
760     */
761    protected function readLocalInfo()
762    {
763        if (!$this->getCurrentDir()) return;
764        $file = $this->currentDir . '/' . $this->type . '.info.txt';
765        if (!is_readable($file)) return;
766        $this->localInfo = confToHash($file, true);
767        $this->localInfo = array_filter($this->localInfo); // remove all falsy keys
768    }
769
770    /**
771     * Fetches the remote info from the repository
772     *
773     * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case
774     */
775    protected function loadRemoteInfo()
776    {
777        if ($this->remoteInfo) return;
778        $remote = Repository::getInstance();
779        try {
780            $this->remoteInfo = (array)$remote->getExtensionData($this->getId());
781        } catch (Exception $e) {
782            $this->remoteInfo = [];
783        }
784    }
785
786    /**
787     * Read information from either local or remote info
788     *
789     * Always prefers local info over remote info. Giving multiple keys is useful when the
790     * key has been renamed in the past or if local and remote keys might differ.
791     *
792     * @param string|string[] $tag one or multiple keys to check
793     * @param mixed $default
794     * @return mixed
795     */
796    protected function getTag($tag, $default = '')
797    {
798        foreach ((array)$tag as $t) {
799            if (isset($this->localInfo[$t])) return $this->localInfo[$t];
800        }
801
802        return $this->getRemoteTag($tag, $default);
803    }
804
805    /**
806     * Read information from remote info
807     *
808     * @param string|string[] $tag one or mutiple keys to check
809     * @param mixed $default
810     * @return mixed
811     */
812    protected function getRemoteTag($tag, $default = '')
813    {
814        $this->loadRemoteInfo();
815        foreach ((array)$tag as $t) {
816            if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t];
817        }
818        return $default;
819    }
820
821    // endregion
822
823    // region utilities
824
825    /**
826     * Convert an extension id to a type and base
827     *
828     * @param string $id
829     * @return array [type, base]
830     */
831    protected function idToTypeBase($id)
832    {
833        [$type, $base] = sexplode(':', $id, 2);
834        if ($base === null) {
835            $base = $type;
836            $type = self::TYPE_PLUGIN;
837        } elseif ($type === self::TYPE_TEMPLATE) {
838            $type = self::TYPE_TEMPLATE;
839        } else {
840            throw new RuntimeException('Invalid extension id: ' . $id);
841        }
842
843        return [$type, $base];
844    }
845
846    /**
847     * @return string
848     */
849    public function __toString()
850    {
851        return $this->getId();
852    }
853
854    // endregion
855}
856