xref: /dokuwiki/lib/plugins/extension/Extension.php (revision a1e045f72e3be1dcea57282e31aeb0835ab73e23)
1<?php
2
3namespace dokuwiki\plugin\extension;
4
5use dokuwiki\Extension\PluginController;
6use dokuwiki\Utf8\PhpString;
7use RuntimeException;
8
9class Extension
10{
11    const TYPE_PLUGIN = 'plugin';
12    const TYPE_TEMPLATE = 'template';
13
14    /** @var string "plugin"|"template" */
15    protected string $type = self::TYPE_PLUGIN;
16
17    /** @var string The base name of this extension */
18    protected string $base;
19
20    /** @var string|null The current location of this extension */
21    protected ?string $currentDir;
22
23    /** @var array The local info array of the extension */
24    protected array $localInfo = [];
25
26    /** @var array The remote info array of the extension */
27    protected array $remoteInfo = [];
28
29    /** @var array The manager info array of the extension */
30    protected array $managerInfo = [];
31
32    // region Constructors
33
34    /**
35     * The main constructor is private to force the use of the factory methods
36     */
37    protected function __construct()
38    {
39    }
40
41    /**
42     * Initializes an extension from an id
43     *
44     * @param string $id The id of the extension
45     * @return Extension
46     */
47    public static function createFromId($id)
48    {
49        $extension = new self();
50        $extension->initFromId($id);
51        return $extension;
52    }
53
54    protected function initFromId($id)
55    {
56        [$type, $base] = $this->idToTypeBase($id);
57        $this->type = $type;
58        $this->base = $base;
59        $this->readLocalInfo();
60    }
61
62    /**
63     * Initializes an extension from a directory
64     *
65     * The given directory might be the one where the extension has already been installed to
66     * or it might be the extracted source in some temporary directory.
67     *
68     * @param string $dir Where the extension code is currently located
69     * @param string|null $type TYPE_PLUGIN|TYPE_TEMPLATE, null for auto-detection
70     * @param string $base The base name of the extension, null for auto-detection
71     * @return Extension
72     */
73    public static function createFromDirectory($dir, $type = null, $base = null)
74    {
75        $extension = new self();
76        $extension->initFromDirectory($dir, $type, $base);
77        return $extension;
78    }
79
80    protected function initFromDirectory($dir, $type = null, $base = null)
81    {
82        if (!is_dir($dir)) throw new RuntimeException('Directory not found: ' . $dir);
83        $this->currentDir = realpath($dir);
84
85        if ($type === null || $type === self::TYPE_TEMPLATE) {
86            if (
87                file_exists($dir . '/template.info.php') ||
88                file_exists($dir . '/style.ini') ||
89                file_exists($dir . '/main.php') ||
90                file_exists($dir . '/detail.php') ||
91                file_exists($dir . '/mediamanager.php')
92            ) {
93                $this->type = self::TYPE_TEMPLATE;
94            }
95        } else {
96            $this->type = self::TYPE_PLUGIN;
97        }
98
99        $this->readLocalInfo();
100
101        if ($base !== null) {
102            $this->base = $base;
103        } elseif (isset($this->localInfo['base'])) {
104            $this->base = $this->localInfo['base'];
105        } else {
106            $this->base = basename($dir);
107        }
108    }
109
110    /**
111     * Initializes an extension from remote data
112     *
113     * @param array $data The data as returned by the repository api
114     * @return Extension
115     */
116    public static function createFromRemoteData($data)
117    {
118        $extension = new self();
119        $extension->initFromRemoteData($data);
120        return $extension;
121    }
122
123    protected function initFromRemoteData($data)
124    {
125        if (!isset($data['plugin'])) throw new RuntimeException('Invalid remote data');
126
127        [$type, $base] = $this->idToTypeBase($data['plugin']);
128        $this->remoteInfo = $data;
129        $this->type = $type;
130        $this->base = $base;
131
132        if ($this->isInstalled()) {
133            $this->currentDir = $this->getInstallDir();
134            $this->readLocalInfo();
135        }
136    }
137
138    // endregion
139
140    // region Getters
141
142    /**
143     * @return string The extension id (same as base but prefixed with "template:" for templates)
144     */
145    public function getId()
146    {
147        if ($this->type === self::TYPE_TEMPLATE) {
148            return self::TYPE_TEMPLATE . ':' . $this->base;
149        }
150        return $this->base;
151    }
152
153    /**
154     * Get the base name of this extension
155     *
156     * @return string
157     */
158    public function getBase()
159    {
160        return $this->base;
161    }
162
163    /**
164     * Get the type of the extension
165     *
166     * @return string "plugin"|"template"
167     */
168    public function getType()
169    {
170        return $this->type;
171    }
172
173    /**
174     * The current directory of the extension
175     *
176     * @return string|null
177     */
178    public function getCurrentDir()
179    {
180        // recheck that the current currentDir is still valid
181        if ($this->currentDir && !is_dir($this->currentDir)) {
182            $this->currentDir = null;
183        }
184
185        // if the extension is installed, then the currentDir is the install dir!
186        if (!$this->currentDir && $this->isInstalled()) {
187            $this->currentDir = $this->getInstallDir();
188        }
189
190        return $this->currentDir;
191    }
192
193    /**
194     * Get the directory where this extension should be installed in
195     *
196     * Note: this does not mean that the extension is actually installed there
197     *
198     * @return string
199     */
200    public function getInstallDir()
201    {
202        if ($this->isTemplate()) {
203            $dir = dirname(tpl_incdir()) . $this->base;
204        } else {
205            $dir = DOKU_PLUGIN . $this->base;
206        }
207
208        return realpath($dir);
209    }
210
211
212    /**
213     * Get the display name of the extension
214     *
215     * @return string
216     */
217    public function getDisplayName()
218    {
219        return $this->getTag('name', PhpString::ucwords($this->getBase() . ' ' . $this->getType()));
220    }
221
222    /**
223     * Get the author name of the extension
224     *
225     * @return string Returns an empty string if the author info is missing
226     */
227    public function getAuthor()
228    {
229        return $this->getTag('author');
230    }
231
232    /**
233     * Get the email of the author of the extension if there is any
234     *
235     * @return string Returns an empty string if the email info is missing
236     */
237    public function getEmail()
238    {
239        // email is only in the local data
240        return $this->localInfo['email'] ?? '';
241    }
242
243    /**
244     * Get the email id, i.e. the md5sum of the email
245     *
246     * @return string Empty string if no email is available
247     */
248    public function getEmailID()
249    {
250        if (!empty($this->remoteInfo['emailid'])) return $this->remoteInfo['emailid'];
251        if (!empty($this->localInfo['email'])) return md5($this->localInfo['email']);
252        return '';
253    }
254
255    /**
256     * Get the description of the extension
257     *
258     * @return string Empty string if no description is available
259     */
260    public function getDescription()
261    {
262        return $this->getTag(['desc', 'description']);
263    }
264
265    /**
266     * Get the URL of the extension, usually a page on dokuwiki.org
267     *
268     * @return string
269     */
270    public function getURL()
271    {
272        return $this->getTag(
273            'url',
274            'https://www.dokuwiki.org/' .
275            ($this->isTemplate() ? 'template' : 'plugin') . ':' . $this->getBase()
276        );
277    }
278
279    /**
280     * Is this extension a template?
281     *
282     * @return bool false if it is a plugin
283     */
284    public function isTemplate()
285    {
286        return $this->type === self::TYPE_TEMPLATE;
287    }
288
289    /**
290     * Is the extension installed locally?
291     *
292     * @return bool
293     */
294    public function isInstalled()
295    {
296        return is_dir($this->getInstallDir());
297    }
298
299    /**
300     * Is the extension under git control?
301     *
302     * @return bool
303     */
304    public function isGitControlled()
305    {
306        if (!$this->isInstalled()) return false;
307        return file_exists($this->getInstallDir() . '/.git');
308    }
309
310    /**
311     * If the extension is bundled
312     *
313     * @return bool If the extension is bundled
314     */
315    public function isBundled()
316    {
317        $this->loadRemoteInfo();
318        return $this->remoteInfo['bundled'] ?? in_array(
319            $this->getId(),
320            [
321                'authad',
322                'authldap',
323                'authpdo',
324                'authplain',
325                'acl',
326                'config',
327                'extension',
328                'info',
329                'popularity',
330                'revert',
331                'safefnrecode',
332                'styling',
333                'testing',
334                'usermanager',
335                'logviewer',
336                'template:dokuwiki'
337            ]
338        );
339    }
340
341    /**
342     * Is the extension protected against any modification (disable/uninstall)
343     *
344     * @return bool if the extension is protected
345     */
346    public function isProtected()
347    {
348        // never allow deinstalling the current auth plugin:
349        global $conf;
350        if ($this->getId() == $conf['authtype']) return true;
351
352        // FIXME disallow current template to be uninstalled
353
354        /** @var PluginController $plugin_controller */
355        global $plugin_controller;
356        $cascade = $plugin_controller->getCascade();
357        return ($cascade['protected'][$this->getId()] ?? false);
358    }
359
360    /**
361     * Is the extension installed in the correct directory?
362     *
363     * @return bool
364     */
365    public function isInWrongFolder()
366    {
367        return $this->getInstallDir() != $this->currentDir;
368    }
369
370    /**
371     * Is the extension enabled?
372     *
373     * @return bool
374     */
375    public function isEnabled()
376    {
377        global $conf;
378        if ($this->isTemplate()) {
379            return ($conf['template'] == $this->getBase());
380        }
381
382        /* @var PluginController $plugin_controller */
383        global $plugin_controller;
384        return $plugin_controller->isEnabled($this->base);
385    }
386
387    // endregion
388
389    // region Actions
390
391    /**
392     * Install or update the extension
393     *
394     * @throws Exception
395     */
396    public function installOrUpdate()
397    {
398        $installer = new Installer(true);
399        $installer->installFromUrl(
400            $this->getURL(),
401            $this->getBase(),
402        );
403    }
404
405    /**
406     * Uninstall the extension
407     * @throws Exception
408     */
409    public function uninstall()
410    {
411        $installer = new Installer(true);
412        $installer->uninstall($this);
413    }
414
415    /**
416     * Enable the extension
417     * @todo I'm unsure if this code should be here or part of Installer
418     * @throws Exception
419     */
420    public function enable()
421    {
422        if ($this->isTemplate()) throw new Exception('notimplemented');
423        if (!$this->isInstalled()) throw new Exception('notinstalled');
424        if ($this->isEnabled()) throw new Exception('alreadyenabled');
425
426        /* @var PluginController $plugin_controller */
427        global $plugin_controller;
428        if (!$plugin_controller->enable($this->base)) {
429            throw new Exception('pluginlistsaveerror');
430        }
431        Installer::purgeCache();
432    }
433
434    /**
435     * Disable the extension
436     * @todo I'm unsure if this code should be here or part of Installer
437     * @throws Exception
438     */
439    public function disable()
440    {
441        if ($this->isTemplate()) throw new Exception('notimplemented');
442        if (!$this->isInstalled()) throw new Exception('notinstalled');
443        if (!$this->isEnabled()) throw new Exception('alreadydisabled');
444        if ($this->isProtected()) throw new Exception('error_disable_protected');
445
446        /* @var PluginController $plugin_controller */
447        global $plugin_controller;
448        if (!$plugin_controller->disable($this->base)) {
449            throw new Exception('pluginlistsaveerror');
450        }
451        Installer::purgeCache();
452    }
453
454    // endregion
455
456    // region Meta Data Management
457
458    /**
459     * This updates the timestamp and URL in the manager.dat file
460     *
461     * It is called by Installer when installing or updating an extension
462     *
463     * @param $url
464     */
465    public function updateManagerInfo($url)
466    {
467        $this->managerInfo['downloadurl'] = $url;
468        if (isset($this->managerInfo['installed'])) {
469            // it's an update
470            $this->managerInfo['updated'] = date('r');
471        } else {
472            // it's a new install
473            $this->managerInfo['installed'] = date('r');
474        }
475
476        $managerpath = $this->getInstallDir() . '/manager.dat';
477        $data = '';
478        foreach ($this->managerInfo as $k => $v) {
479            $data .= $k . '=' . $v . DOKU_LF;
480        }
481        io_saveFile($managerpath, $data);
482    }
483
484    /**
485     * Reads the manager.dat file and fills the managerInfo array
486     */
487    protected function readManagerInfo()
488    {
489        if ($this->managerInfo) return;
490
491        $managerpath = $this->getInstallDir() . '/manager.dat';
492        if (!is_readable($managerpath)) return;
493
494        $file = (array)@file($managerpath);
495        foreach ($file as $line) {
496            [$key, $value] = sexplode('=', $line, 2, '');
497            $key = trim($key);
498            $value = trim($value);
499            // backwards compatible with old plugin manager
500            if ($key == 'url') $key = 'downloadurl';
501            $this->managerInfo[$key] = $value;
502        }
503    }
504
505    /**
506     * Reads the info file of the extension if available and fills the localInfo array
507     */
508    protected function readLocalInfo()
509    {
510        if (!$this->getCurrentDir()) return;
511        $file = $this->currentDir . '/' . $this->type . '.info.txt';
512        if (!is_readable($file)) return;
513        $this->localInfo = confToHash($file, true);
514        $this->localInfo = array_filter($this->localInfo); // remove all falsy keys
515    }
516
517    /**
518     * Fetches the remote info from the repository
519     *
520     * This ignores any errors coming from the repository and just sets the remoteInfo to an empty array in that case
521     */
522    protected function loadRemoteInfo()
523    {
524        if ($this->remoteInfo) return;
525        $remote = Repository::getInstance();
526        try {
527            $this->remoteInfo = (array)$remote->getExtensionData($this->getId());
528        } catch (Exception $e) {
529            $this->remoteInfo = [];
530        }
531    }
532
533    /**
534     * Read information from either local or remote info
535     *
536     * Always prefers local info over remote info
537     *
538     * @param string|string[] $tag one or multiple keys to check
539     * @param mixed $default
540     * @return mixed
541     */
542    protected function getTag($tag, $default = '')
543    {
544        foreach ((array)$tag as $t) {
545            if (isset($this->localInfo[$t])) return $this->localInfo[$t];
546        }
547        $this->loadRemoteInfo();
548        foreach ((array)$tag as $t) {
549            if (isset($this->remoteInfo[$t])) return $this->remoteInfo[$t];
550        }
551
552        return $default;
553    }
554
555    // endregion
556
557    // region utilities
558
559    /**
560     * Convert an extension id to a type and base
561     *
562     * @param string $id
563     * @return array [type, base]
564     */
565    protected function idToTypeBase($id)
566    {
567        [$type, $base] = sexplode(':', $id, 2);
568        if ($base === null) {
569            $base = $type;
570            $type = self::TYPE_PLUGIN;
571        } elseif ($type === self::TYPE_TEMPLATE) {
572            $type = self::TYPE_TEMPLATE;
573        } else {
574            throw new RuntimeException('Invalid extension id: ' . $id);
575        }
576
577        return [$type, $base];
578    }
579    // endregion
580}
581