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