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