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