xref: /dokuwiki/lib/plugins/extension/Installer.php (revision b69d74f1e111c77de08eb7f64e7f77368a537e44)
1<?php
2
3namespace dokuwiki\plugin\extension;
4
5use dokuwiki\Extension\PluginController;
6use dokuwiki\HTTP\DokuHTTPClient;
7use dokuwiki\Utf8\PhpString;
8use RecursiveDirectoryIterator;
9use RecursiveIteratorIterator;
10use splitbrain\PHPArchive\ArchiveCorruptedException;
11use splitbrain\PHPArchive\ArchiveIllegalCompressionException;
12use splitbrain\PHPArchive\ArchiveIOException;
13use splitbrain\PHPArchive\Tar;
14use splitbrain\PHPArchive\Zip;
15
16/**
17 * Install and deinstall extensions
18 *
19 * This manages all the file operations and downloads needed to install an extension.
20 */
21class Installer
22{
23    /** @var string[] a list of temporary directories used during this installation */
24    protected array $temporary = [];
25
26    /** @var bool if changes have been made that require a cache purge */
27    protected $isDirty = false;
28
29    /** @var bool Replace existing files? */
30    protected $overwrite = false;
31
32    /** @var string The last used URL to install an extension */
33    protected $sourceUrl = '';
34
35    protected $processed = [];
36
37    public const STATUS_SKIPPED = 'skipped';
38    public const STATUS_UPDATED = 'updated';
39    public const STATUS_INSTALLED = 'installed';
40    public const STATUS_REMOVED = 'removed';
41
42
43    /**
44     * Initialize a new extension installer
45     *
46     * @param bool $overwrite
47     */
48    public function __construct($overwrite = false)
49    {
50        $this->overwrite = $overwrite;
51    }
52
53    /**
54     * Destructor
55     *
56     * deletes any dangling temporary directories
57     */
58    public function __destruct()
59    {
60        foreach ($this->temporary as $dir) {
61            io_rmdir($dir, true);
62        }
63        $this->cleanUp();
64    }
65
66    /**
67     * Install an extension by ID
68     *
69     * This will simply call installExtension after constructing an extension from the ID
70     *
71     * The $skipInstalled parameter should only be used when installing dependencies
72     *
73     * @param string $id the extension ID
74     * @param bool $skipInstalled Ignore the overwrite setting and skip installed extensions
75     * @throws Exception
76     */
77    public function installFromId($id, $skipInstalled = false)
78    {
79        $extension = Extension::createFromId($id);
80        if ($skipInstalled && $extension->isInstalled()) return;
81        $this->installExtension($extension);
82    }
83
84    /**
85     * Install an extension
86     *
87     * This will simply call installFromUrl() with the URL from the extension
88     *
89     * @param Extension $extension
90     * @throws Exception
91     */
92    public function installExtension(Extension $extension)
93    {
94        $url = $extension->getDownloadURL();
95        if (!$url) {
96            throw new Exception('error_nourl', [$extension->getId()]);
97        }
98        $this->installFromUrl($url);
99    }
100
101    /**
102     * Install extensions from a given URL
103     *
104     * @param string $url the URL to the archive
105     * @param null $base the base directory name to use
106     * @throws Exception
107     */
108    public function installFromUrl($url, $base = null)
109    {
110        $this->sourceUrl = $url;
111        $archive = $this->downloadArchive($url);
112        $this->installFromArchive(
113            $archive,
114            $base
115        );
116    }
117
118    /**
119     * Install extensions from a user upload
120     *
121     * @param string $field name of the upload file
122     * @throws Exception
123     */
124    public function installFromUpload($field)
125    {
126        $this->sourceUrl = '';
127        if ($_FILES[$field]['error']) {
128            throw new Exception('msg_upload_failed', [$_FILES[$field]['error']]);
129        }
130
131        $tmp = $this->mkTmpDir();
132        if (!move_uploaded_file($_FILES[$field]['tmp_name'], "$tmp/upload.archive")) {
133            throw new Exception('msg_upload_failed', ['move failed']);
134        }
135        $this->installFromArchive(
136            "$tmp/upload.archive",
137            $this->fileToBase($_FILES[$field]['name']),
138        );
139    }
140
141    /**
142     * Install extensions from an archive
143     *
144     * The archive is extracted to a temporary directory and then the contained extensions are installed.
145     * This is is the ultimate installation procedure and all other install methods will end up here.
146     *
147     * @param string $archive the path to the archive
148     * @param string $base the base directory name to use
149     * @throws Exception
150     */
151    public function installFromArchive($archive, $base = null)
152    {
153        if ($base === null) $base = $this->fileToBase($archive);
154        $target = $this->mkTmpDir() . '/' . $base;
155        $this->extractArchive($archive, $target);
156        $extensions = $this->findExtensions($target, $base);
157        foreach ($extensions as $extension) {
158            // check installation status
159            if ($extension->isInstalled()) {
160                if (!$this->overwrite) {
161                    $this->processed[$extension->getId()] = self::STATUS_SKIPPED;
162                    continue;
163                }
164                $status = self::STATUS_UPDATED;
165            } else {
166                $status = self::STATUS_INSTALLED;
167            }
168
169            // check PHP requirements
170            self::ensurePhpCompatibility($extension);
171
172            // install dependencies first
173            foreach ($extension->getDependencyList() as $id) {
174                if (isset($this->processed[$id])) continue;
175                if ($id == $extension->getId()) continue; // avoid circular dependencies
176                $this->installFromId($id, true);
177            }
178
179            // now install the extension
180            self::ensurePermissions($extension);
181            $this->dircopy(
182                $extension->getCurrentDir(),
183                $extension->getInstallDir()
184            );
185            $this->isDirty = true;
186            $extension->getManager()->storeUpdate($this->sourceUrl);
187            $this->removeDeletedFiles($extension);
188            $this->processed[$extension->getId()] = $status;
189        }
190
191        $this->cleanUp();
192    }
193
194    /**
195     * Uninstall an extension
196     *
197     * @param Extension $extension
198     * @throws Exception
199     */
200    public function uninstall(Extension $extension)
201    {
202        if (!$extension->isInstalled()) {
203            throw new Exception('error_notinstalled', [$extension->getId()]);
204        }
205
206        if ($extension->isProtected()) {
207            throw new Exception('error_uninstall_protected', [$extension->getId()]);
208        }
209
210        self::ensurePermissions($extension);
211
212        $dependants = $extension->getDependants();
213        if($dependants !== []) {
214            throw new Exception('error_uninstall_dependants', [$extension->getId(), implode(', ', $dependants)]);
215        }
216
217        if (!io_rmdir($extension->getInstallDir(), true)) {
218            throw new Exception('msg_delete_failed', [$extension->getId()]);
219        }
220        self::purgeCache();
221
222        $this->processed[$extension->getId()] = self::STATUS_REMOVED;
223    }
224
225    /**
226     * Enable the extension
227     *
228     * @throws Exception
229     */
230    public function enable(Extension $extension)
231    {
232        if ($extension->isTemplate()) throw new Exception('notimplemented');
233        if (!$extension->isInstalled()) throw new Exception('error_notinstalled', [$extension->getId()]);
234        if ($extension->isEnabled()) throw new Exception('error_alreadyenabled', [$extension->getId()]);
235
236        /* @var PluginController $plugin_controller */
237        global $plugin_controller;
238        if (!$plugin_controller->enable($extension->getBase())) {
239            throw new Exception('pluginlistsaveerror');
240        }
241        self::purgeCache();
242    }
243
244    /**
245     * Disable the extension
246     *
247     * @throws Exception
248     */
249    public function disable(Extension $extension)
250    {
251        if ($extension->isTemplate()) throw new Exception('notimplemented');
252        if (!$extension->isInstalled()) throw new Exception('error_notinstalled', [$extension->getId()]);
253        if (!$extension->isEnabled()) throw new Exception('error_alreadydisabled', [$extension->getId()]);
254        if ($extension->isProtected()) throw new Exception('error_disable_protected', [$extension->getId()]);
255
256        $dependants = $extension->getDependants();
257        if($dependants !== []) {
258            throw new Exception('error_disable_dependants', [$extension->getId(), implode(', ', $dependants)]);
259        }
260
261        /* @var PluginController $plugin_controller */
262        global $plugin_controller;
263        if (!$plugin_controller->disable($extension->getBase())) {
264            throw new Exception('pluginlistsaveerror');
265        }
266        self::purgeCache();
267    }
268
269
270    /**
271     * Download an archive to a protected path
272     *
273     * @param string $url The url to get the archive from
274     * @return string The path where the archive was saved
275     * @throws Exception
276     */
277    public function downloadArchive($url)
278    {
279        // check the url
280        if (!preg_match('/https?:\/\//i', $url)) {
281            throw new Exception('error_badurl');
282        }
283
284        // try to get the file from the path (used as plugin name fallback)
285        $file = parse_url($url, PHP_URL_PATH);
286        $file = $file ? PhpString::basename($file) : md5($url);
287
288        // download
289        $http = new DokuHTTPClient();
290        $http->max_bodysize = 0;
291        $http->timeout = 25; //max. 25 sec
292        $http->keep_alive = false; // we do single ops here, no need for keep-alive
293        $http->agent = 'DokuWiki HTTP Client (Extension Manager)';
294
295        $data = $http->get($url);
296        if ($data === false) throw new Exception('error_download', [$url, $http->error, $http->status]);
297
298        // get filename from headers
299        if (
300            preg_match(
301                '/attachment;\s*filename\s*=\s*"([^"]*)"/i',
302                (string)($http->resp_headers['content-disposition'] ?? ''),
303                $match
304            )
305        ) {
306            $file = PhpString::basename($match[1]);
307        }
308
309        // clean up filename
310        $file = $this->fileToBase($file);
311
312        // create tmp directory for download
313        $tmp = $this->mkTmpDir();
314
315        // save the file
316        if (@file_put_contents("$tmp/$file", $data) === false) {
317            throw new Exception('error_save');
318        }
319
320        return "$tmp/$file";
321    }
322
323
324    /**
325     * Delete outdated files
326     */
327    public function removeDeletedFiles(Extension $extension)
328    {
329        $extensiondir = $extension->getInstallDir();
330        $definitionfile = $extensiondir . '/deleted.files';
331        if (!file_exists($definitionfile)) return;
332
333        $list = file($definitionfile);
334        foreach ($list as $line) {
335            $line = trim(preg_replace('/#.*$/', '', $line));
336            $line = str_replace('..', '', $line); // do not run out of the extension directory
337            if (!$line) continue;
338
339            $file = $extensiondir . '/' . $line;
340            if (!file_exists($file)) continue;
341
342            io_rmdir($file, true);
343        }
344    }
345
346    /**
347     * Purge all caches
348     */
349    public static function purgeCache()
350    {
351        // expire dokuwiki caches
352        // touching local.php expires wiki page, JS and CSS caches
353        global $config_cascade;
354        @touch(reset($config_cascade['main']['local']));
355
356        if (function_exists('opcache_reset')) {
357            opcache_reset();
358        }
359    }
360
361    /**
362     * Get the list of processed extensions and their status during an installation run
363     *
364     * @return array id => status
365     */
366    public function getProcessed()
367    {
368        return $this->processed;
369    }
370
371
372    /**
373     * Ensure that the given extension is compatible with the current PHP version
374     *
375     * Throws an exception if the extension is not compatible
376     *
377     * @param Extension $extension
378     * @throws Exception
379     */
380    public static function ensurePhpCompatibility(Extension $extension)
381    {
382        $min = $extension->getMinimumPHPVersion();
383        if ($min && version_compare(PHP_VERSION, $min, '<')) {
384            throw new Exception('error_minphp', [$extension->getId(), $min, PHP_VERSION]);
385        }
386
387        $max = $extension->getMaximumPHPVersion();
388        if ($max && version_compare(PHP_VERSION, $max, '>')) {
389            throw new Exception('error_maxphp', [$extension->getId(), $max, PHP_VERSION]);
390        }
391    }
392
393    /**
394     * Ensure the file permissions are correct before attempting to install
395     *
396     * @throws Exception if the permissions are not correct
397     */
398    public static function ensurePermissions(Extension $extension)
399    {
400        $target = $extension->getInstallDir();
401
402        // updates
403        if (file_exists($target)) {
404            if (!is_writable($target)) throw new Exception('noperms');
405            return;
406        }
407
408        // new installs
409        $target = dirname($target);
410        if (!is_writable($target)) {
411            if ($extension->isTemplate()) throw new Exception('notplperms');
412            throw new Exception('nopluginperms');
413        }
414    }
415
416    /**
417     * Get a base name from an archive name (we don't trust)
418     *
419     * @param string $file
420     * @return string
421     */
422    protected function fileToBase($file)
423    {
424        $base = PhpString::basename($file);
425        $base = preg_replace('/\.(tar\.gz|tar\.bz|tar\.bz2|tar|tgz|tbz|zip)$/', '', $base);
426        return preg_replace('/\W+/', '', $base);
427    }
428
429    /**
430     * Returns a temporary directory
431     *
432     * The directory is registered for cleanup when the class is destroyed
433     *
434     * @return string
435     * @throws Exception
436     */
437    protected function mkTmpDir()
438    {
439        try {
440            $dir = io_mktmpdir();
441        } catch (\Exception $e) {
442            throw new Exception('error_dircreate', [], $e);
443        }
444        if (!$dir) throw new Exception('error_dircreate');
445        $this->temporary[] = $dir;
446        return $dir;
447    }
448
449    /**
450     * Find all extensions in a given directory
451     *
452     * This allows us to install extensions from archives that contain multiple extensions and
453     * also caters for the fact that archives may or may not contain subdirectories for the extension(s).
454     *
455     * @param string $dir
456     * @return Extension[]
457     */
458    protected function findExtensions($dir, $base = null)
459    {
460        // first check for plugin.info.txt or template.info.txt
461        $extensions = [];
462        $iterator = new RecursiveDirectoryIterator($dir);
463        foreach (new RecursiveIteratorIterator($iterator) as $file) {
464            if (
465                $file->getFilename() === 'plugin.info.txt' ||
466                $file->getFilename() === 'template.info.txt'
467            ) {
468                $extensions[] = Extension::createFromDirectory($file->getPath());
469            }
470        }
471        if ($extensions) return $extensions;
472
473        // still nothing? we assume this to be a single extension that is either
474        // directly in the given directory or in single subdirectory
475        $files = glob($dir . '/*');
476        if (count($files) === 1 && is_dir($files[0])) {
477            $dir = $files[0];
478        }
479        $base ??= PhpString::basename($dir);
480        return [Extension::createFromDirectory($dir, null, $base)];
481    }
482
483    /**
484     * Extract the given archive to the given target directory
485     *
486     * Auto-guesses the archive type
487     * @throws Exception
488     */
489    protected function extractArchive($archive, $target)
490    {
491        $fh = fopen($archive, 'rb');
492        if (!$fh) throw new Exception('error_archive_read', [$archive]);
493        $magic = fread($fh, 5);
494        fclose($fh);
495
496        if (strpos($magic, "\x50\x4b\x03\x04") === 0) {
497            $archiver = new Zip();
498        } else {
499            $archiver = new Tar();
500        }
501        try {
502            $archiver->open($archive);
503            $archiver->extract($target);
504        } catch (ArchiveIOException | ArchiveCorruptedException | ArchiveIllegalCompressionException $e) {
505            throw new Exception('error_archive_extract', [$archive, $e->getMessage()], $e);
506        }
507    }
508
509    /**
510     * Copy with recursive sub-directory support
511     *
512     * @param string $src filename path to file
513     * @param string $dst filename path to file
514     * @throws Exception
515     */
516    protected function dircopy($src, $dst)
517    {
518        global $conf;
519
520        if (is_dir($src)) {
521            if (!$dh = @opendir($src)) {
522                throw new Exception('error_copy_read', [$src]);
523            }
524
525            if (io_mkdir_p($dst)) {
526                while (false !== ($f = readdir($dh))) {
527                    if ($f == '..' || $f == '.') continue;
528                    $this->dircopy("$src/$f", "$dst/$f");
529                }
530            } else {
531                throw new Exception('error_copy_mkdir', [$dst]);
532            }
533
534            closedir($dh);
535        } else {
536            $existed = file_exists($dst);
537
538            if (!@copy($src, $dst)) {
539                throw new Exception('error_copy_copy', [$src, $dst]);
540            }
541            if (!$existed && $conf['fperm']) chmod($dst, $conf['fperm']);
542            @touch($dst, filemtime($src));
543        }
544    }
545
546    /**
547     * Reset caches if needed
548     */
549    protected function cleanUp()
550    {
551        if ($this->isDirty) {
552            self::purgeCache();
553            $this->isDirty = false;
554        }
555    }
556}
557