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        $url = $this->getDownloadURL();
511        if (!$url) return false;
512        return $last !== $url;
513    }
514
515    /**
516     * Is an update available for this extension?
517     *
518     * @return bool
519     */
520    public function isUpdateAvailable()
521    {
522        if ($this->isBundled()) return false; // bundled extensions are never updated
523        $self = $this->getInstalledVersion();
524        $remote = $this->getLastUpdate();
525        return $self < $remote;
526    }
527
528    // endregion
529
530    // region Remote Info
531
532    /**
533     * Get the date of the last available update
534     *
535     * @return string yyyy-mm-dd
536     */
537    public function getLastUpdate()
538    {
539        return $this->getRemoteTag('lastupdate');
540    }
541
542    /**
543     * Get a list of tags this extension is tagged with at dokuwiki.org
544     *
545     * @return string[]
546     */
547    public function getTags()
548    {
549        return $this->getRemoteTag('tags', []);
550    }
551
552    /**
553     * Get the popularity of the extension
554     *
555     * This is a float between 0 and 1
556     *
557     * @return float
558     */
559    public function getPopularity()
560    {
561        return (float)$this->getRemoteTag('popularity', 0);
562    }
563
564    /**
565     * Get the text of the update message if there is any
566     *
567     * @return string
568     */
569    public function getUpdateMessage()
570    {
571        return $this->getRemoteTag('updatemessage');
572    }
573
574    /**
575     * Get the text of the security warning if there is any
576     *
577     * @return string
578     */
579    public function getSecurityWarning()
580    {
581        return $this->getRemoteTag('securitywarning');
582    }
583
584    /**
585     * Get the text of the security issue if there is any
586     *
587     * @return string
588     */
589    public function getSecurityIssue()
590    {
591        return $this->getRemoteTag('securityissue');
592    }
593
594    /**
595     * Get the URL of the screenshot of the extension if there is any
596     *
597     * @return string
598     */
599    public function getScreenshotURL()
600    {
601        return $this->getRemoteTag('screenshoturl');
602    }
603
604    /**
605     * Get the URL of the thumbnail of the extension if there is any
606     *
607     * @return string
608     */
609    public function getThumbnailURL()
610    {
611        return $this->getRemoteTag('thumbnailurl');
612    }
613
614    /**
615     * Get the download URL of the extension if there is any
616     *
617     * @return string
618     */
619    public function getDownloadURL()
620    {
621        return $this->getRemoteTag('downloadurl');
622    }
623
624    /**
625     * Get the bug tracker URL of the extension if there is any
626     *
627     * @return string
628     */
629    public function getBugtrackerURL()
630    {
631        return $this->getRemoteTag('bugtracker');
632    }
633
634    /**
635     * Get the URL of the source repository if there is any
636     *
637     * @return string
638     */
639    public function getSourcerepoURL()
640    {
641        return $this->getRemoteTag('sourcerepo');
642    }
643
644    /**
645     * Get the donation URL of the extension if there is any
646     *
647     * @return string
648     */
649    public function getDonationURL()
650    {
651        return $this->getRemoteTag('donationurl');
652    }
653
654    /**
655     * Get a list of extensions that are similar to this one
656     *
657     * @return string[]
658     */
659    public function getSimilarList()
660    {
661        return $this->getRemoteTag('similar', []);
662    }
663
664    /**
665     * Get a list of extensions that are marked as conflicting with this one
666     *
667     * @return string[]
668     */
669    public function getConflictList()
670    {
671        return $this->getRemoteTag('conflicts', []);
672    }
673
674    /**
675     * Get a list of DokuWiki versions this plugin is marked as compatible with
676     *
677     * @return string[][] date -> version
678     */
679    public function getCompatibleVersions()
680    {
681        return $this->getRemoteTag('compatible', []);
682    }
683
684    // endregion
685
686    // region Actions
687
688    /**
689     * Install or update the extension
690     *
691     * @throws Exception
692     */
693    public function installOrUpdate()
694    {
695        $installer = new Installer(true);
696        $installer->installExtension($this);
697    }
698
699    /**
700     * Uninstall the extension
701     * @throws Exception
702     */
703    public function uninstall()
704    {
705        $installer = new Installer(true);
706        $installer->uninstall($this);
707    }
708
709    /**
710     * Toggle the extension between enabled and disabled
711     * @return void
712     * @throws Exception
713     */
714    public function toggle()
715    {
716        if ($this->isEnabled()) {
717            $this->disable();
718        } else {
719            $this->enable();
720        }
721    }
722
723    /**
724     * Enable the extension
725     *
726     * @throws Exception
727     */
728    public function enable()
729    {
730        (new Installer())->enable($this);
731    }
732
733    /**
734     * Disable the extension
735     *
736     * @throws Exception
737     */
738    public function disable()
739    {
740        (new Installer())->disable($this);
741    }
742
743    // endregion
744
745    // region Meta Data Management
746
747    /**
748     * Access the Manager for this extension
749     *
750     * @return Manager
751     */
752    public function getManager()
753    {
754        if (!$this->manager instanceof Manager) {
755            $this->manager = new Manager($this);
756        }
757        return $this->manager;
758    }
759
760    /**
761     * Reads the info file of the extension if available and fills the localInfo array
762     */
763    protected function readLocalInfo()
764    {
765        if (!$this->getCurrentDir()) return;
766        $file = $this->currentDir . '/' . $this->type . '.info.txt';
767        if (!is_readable($file)) return;
768        $this->localInfo = confToHash($file, true);
769        $this->localInfo = array_filter($this->localInfo); // remove all falsy keys
770    }
771
772    /**
773     * Fetches the remote info from the repository
774     *
775     * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case
776     */
777    protected function loadRemoteInfo()
778    {
779        if ($this->remoteInfo) return;
780        $remote = Repository::getInstance();
781        try {
782            $this->remoteInfo = (array)$remote->getExtensionData($this->getId());
783        } catch (Exception $e) {
784            $this->remoteInfo = [];
785        }
786    }
787
788    /**
789     * Read information from either local or remote info
790     *
791     * Always prefers local info over remote info. Giving multiple keys is useful when the
792     * key has been renamed in the past or if local and remote keys might differ.
793     *
794     * @param string|string[] $tag one or multiple keys to check
795     * @param mixed $default
796     * @return mixed
797     */
798    protected function getTag($tag, $default = '')
799    {
800        foreach ((array)$tag as $t) {
801            if (isset($this->localInfo[$t])) return $this->localInfo[$t];
802        }
803
804        return $this->getRemoteTag($tag, $default);
805    }
806
807    /**
808     * Read information from remote info
809     *
810     * @param string|string[] $tag one or mutiple keys to check
811     * @param mixed $default
812     * @return mixed
813     */
814    protected function getRemoteTag($tag, $default = '')
815    {
816        $this->loadRemoteInfo();
817        foreach ((array)$tag as $t) {
818            if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t];
819        }
820        return $default;
821    }
822
823    // endregion
824
825    // region utilities
826
827    /**
828     * Convert an extension id to a type and base
829     *
830     * @param string $id
831     * @return array [type, base]
832     */
833    protected function idToTypeBase($id)
834    {
835        [$type, $base] = sexplode(':', $id, 2);
836        if ($base === null) {
837            $base = $type;
838            $type = self::TYPE_PLUGIN;
839        } elseif ($type === self::TYPE_TEMPLATE) {
840            $type = self::TYPE_TEMPLATE;
841        } else {
842            throw new RuntimeException('Invalid extension id: ' . $id);
843        }
844
845        return [$type, $base];
846    }
847
848    /**
849     * @return string
850     */
851    public function __toString()
852    {
853        return $this->getId();
854    }
855
856    // endregion
857}
858