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->keep_alive = false; // we do single ops here, no need for keep-alive
292        $http->agent = 'DokuWiki HTTP Client (Extension Manager)';
293
294        // large downloads may take a while on slow connections, so we try to extend the timeout to 4 minutes
295        // 4 minutes was chosen, because HTTP servers and proxies often have a 5 minute timeout
296        if (PHP_SAPI === 'cli' || @set_time_limit(60 * 4)) {
297            $http->timeout = 60 * 4 - 5; // nearly 4 minutes
298        } else {
299            $http->timeout = 25; // max. 25 sec (a bit less than default execution time)
300        }
301
302        $data = $http->get($url);
303        if ($data === false) throw new Exception('error_download', [$url, $http->error, $http->status]);
304
305        // get filename from headers
306        if (
307            preg_match(
308                '/attachment;\s*filename\s*=\s*"([^"]*)"/i',
309                (string)($http->resp_headers['content-disposition'] ?? ''),
310                $match
311            )
312        ) {
313            $file = PhpString::basename($match[1]);
314        }
315
316        // clean up filename
317        $file = $this->fileToBase($file);
318
319        // create tmp directory for download
320        $tmp = $this->mkTmpDir();
321
322        // save the file
323        if (@file_put_contents("$tmp/$file", $data) === false) {
324            throw new Exception('error_save');
325        }
326
327        return "$tmp/$file";
328    }
329
330
331    /**
332     * Delete outdated files
333     */
334    public function removeDeletedFiles(Extension $extension)
335    {
336        $extensiondir = $extension->getInstallDir();
337        $definitionfile = $extensiondir . '/deleted.files';
338        if (!file_exists($definitionfile)) return;
339
340        $list = file($definitionfile);
341        foreach ($list as $line) {
342            $line = trim(preg_replace('/#.*$/', '', $line));
343            $line = str_replace('..', '', $line); // do not run out of the extension directory
344            if (!$line) continue;
345
346            $file = $extensiondir . '/' . $line;
347            if (!file_exists($file)) continue;
348
349            io_rmdir($file, true);
350        }
351    }
352
353    /**
354     * Purge all caches
355     */
356    public static function purgeCache()
357    {
358        // expire dokuwiki caches
359        // touching local.php expires wiki page, JS and CSS caches
360        global $config_cascade;
361        @touch(reset($config_cascade['main']['local']));
362
363        if (function_exists('opcache_reset')) {
364            @opcache_reset();
365        }
366    }
367
368    /**
369     * Get the list of processed extensions and their status during an installation run
370     *
371     * @return array id => status
372     */
373    public function getProcessed()
374    {
375        return $this->processed;
376    }
377
378
379    /**
380     * Ensure that the given extension is compatible with the current PHP version
381     *
382     * Throws an exception if the extension is not compatible
383     *
384     * @param Extension $extension
385     * @throws Exception
386     */
387    public static function ensurePhpCompatibility(Extension $extension)
388    {
389        $min = $extension->getMinimumPHPVersion();
390        if ($min && version_compare(PHP_VERSION, $min, '<')) {
391            throw new Exception('error_minphp', [$extension->getId(), $min, PHP_VERSION]);
392        }
393
394        $max = $extension->getMaximumPHPVersion();
395        if ($max && version_compare(PHP_VERSION, $max, '>')) {
396            throw new Exception('error_maxphp', [$extension->getId(), $max, PHP_VERSION]);
397        }
398    }
399
400    /**
401     * Ensure the file permissions are correct before attempting to install
402     *
403     * @throws Exception if the permissions are not correct
404     */
405    public static function ensurePermissions(Extension $extension)
406    {
407        $target = $extension->getInstallDir();
408
409        // updates
410        if (file_exists($target)) {
411            if (!is_writable($target)) throw new Exception('noperms');
412            return;
413        }
414
415        // new installs
416        $target = dirname($target);
417        if (!is_writable($target)) {
418            if ($extension->isTemplate()) throw new Exception('notplperms');
419            throw new Exception('nopluginperms');
420        }
421    }
422
423    /**
424     * Get a base name from an archive name (we don't trust)
425     *
426     * @param string $file
427     * @return string
428     */
429    protected function fileToBase($file)
430    {
431        $base = PhpString::basename($file);
432        $base = preg_replace('/\.(tar\.gz|tar\.bz|tar\.bz2|tar|tgz|tbz|zip)$/', '', $base);
433        return preg_replace('/\W+/', '', $base);
434    }
435
436    /**
437     * Returns a temporary directory
438     *
439     * The directory is registered for cleanup when the class is destroyed
440     *
441     * @return string
442     * @throws Exception
443     */
444    protected function mkTmpDir()
445    {
446        try {
447            $dir = io_mktmpdir();
448        } catch (\Exception $e) {
449            throw new Exception('error_dircreate', [], $e);
450        }
451        if (!$dir) throw new Exception('error_dircreate');
452        $this->temporary[] = $dir;
453        return $dir;
454    }
455
456    /**
457     * Find all extensions in a given directory
458     *
459     * This allows us to install extensions from archives that contain multiple extensions and
460     * also caters for the fact that archives may or may not contain subdirectories for the extension(s).
461     *
462     * @param string $dir
463     * @return Extension[]
464     */
465    protected function findExtensions($dir, $base = null)
466    {
467        // first check for plugin.info.txt or template.info.txt
468        $extensions = [];
469        $iterator = new RecursiveDirectoryIterator($dir);
470        foreach (new RecursiveIteratorIterator($iterator) as $file) {
471            if (
472                $file->getFilename() === 'plugin.info.txt' ||
473                $file->getFilename() === 'template.info.txt'
474            ) {
475                $extensions[] = Extension::createFromDirectory($file->getPath());
476            }
477        }
478        if ($extensions) return $extensions;
479
480        // still nothing? we assume this to be a single extension that is either
481        // directly in the given directory or in single subdirectory
482        $files = glob($dir . '/*');
483        if (count($files) === 1 && is_dir($files[0])) {
484            $dir = $files[0];
485        }
486        $base ??= PhpString::basename($dir);
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