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