xref: /dokuwiki/lib/plugins/extension/Installer.php (revision 7c184cfca36dde23d8ddd540c4826dfe1f86e2e3)
1<?php
2
3namespace dokuwiki\plugin\extension;
4
5use dokuwiki\HTTP\DokuHTTPClient;
6use dokuwiki\Utf8\PhpString;
7use RecursiveDirectoryIterator;
8use RecursiveIteratorIterator;
9use splitbrain\PHPArchive\ArchiveCorruptedException;
10use splitbrain\PHPArchive\ArchiveIllegalCompressionException;
11use splitbrain\PHPArchive\ArchiveIOException;
12use splitbrain\PHPArchive\Tar;
13use splitbrain\PHPArchive\Zip;
14
15/**
16 * Install and deinstall extensions
17 *
18 * This manages all the file operations and downloads needed to install an extension.
19 */
20class Installer
21{
22    /** @var string[] a list of temporary directories used during this installation */
23    protected array $temporary = [];
24
25    /** @var bool if changes have been made that require a cache purge */
26    protected $isDirty = false;
27
28    /** @var bool Replace existing files? */
29    protected $overwrite = false;
30
31    /** @var string The last used URL to install an extension */
32    protected $sourceUrl = '';
33
34    protected $processed = [];
35
36    public const STATUS_SKIPPED = 'skipped';
37    public const STATUS_UPDATED = 'updated';
38    public const STATUS_INSTALLED = 'installed';
39    public const STATUS_REMOVED = 'removed';
40
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        // FIXME check if dependencies are still needed
202        // FIXME check if other extensions depend on this one
203
204        if (!$extension->isInstalled()) {
205            throw new Exception('error_notinstalled', [$extension->getId()]);
206        }
207
208        if ($extension->isProtected()) {
209            throw new Exception('error_uninstall_protected', [$extension->getId()]);
210        }
211
212        self::ensurePermissions($extension);
213
214        if (!io_rmdir($extension->getInstallDir(), true)) {
215            throw new Exception('msg_delete_failed', [$extension->getId()]);
216        }
217        self::purgeCache();
218
219        $this->processed[$extension->getId()] = self::STATUS_REMOVED;
220    }
221
222    /**
223     * Download an archive to a protected path
224     *
225     * @param string $url The url to get the archive from
226     * @return string The path where the archive was saved
227     * @throws Exception
228     */
229    public function downloadArchive($url)
230    {
231        // check the url
232        if (!preg_match('/https?:\/\//i', $url)) {
233            throw new Exception('error_badurl');
234        }
235
236        // try to get the file from the path (used as plugin name fallback)
237        $file = parse_url($url, PHP_URL_PATH);
238        $file = $file ? PhpString::basename($file) : md5($url);
239
240        // download
241        $http = new DokuHTTPClient();
242        $http->max_bodysize = 0;
243        $http->timeout = 25; //max. 25 sec
244        $http->keep_alive = false; // we do single ops here, no need for keep-alive
245        $http->agent = 'DokuWiki HTTP Client (Extension Manager)';
246
247        $data = $http->get($url);
248        if ($data === false) throw new Exception('error_download', [$url, $http->error, $http->status]);
249
250        // get filename from headers
251        if (
252            preg_match(
253                '/attachment;\s*filename\s*=\s*"([^"]*)"/i',
254                (string)($http->resp_headers['content-disposition'] ?? ''),
255                $match
256            )
257        ) {
258            $file = PhpString::basename($match[1]);
259        }
260
261        // clean up filename
262        $file = $this->fileToBase($file);
263
264        // create tmp directory for download
265        $tmp = $this->mkTmpDir();
266
267        // save the file
268        if (@file_put_contents("$tmp/$file", $data) === false) {
269            throw new Exception('error_save');
270        }
271
272        return "$tmp/$file";
273    }
274
275
276    /**
277     * Delete outdated files
278     */
279    public function removeDeletedFiles(Extension $extension)
280    {
281        $extensiondir = $extension->getInstallDir();
282        $definitionfile = $extensiondir . '/deleted.files';
283        if (!file_exists($definitionfile)) return;
284
285        $list = file($definitionfile);
286        foreach ($list as $line) {
287            $line = trim(preg_replace('/#.*$/', '', $line));
288            $line = str_replace('..', '', $line); // do not run out of the extension directory
289            if (!$line) continue;
290
291            $file = $extensiondir . '/' . $line;
292            if (!file_exists($file)) continue;
293
294            io_rmdir($file, true);
295        }
296    }
297
298    /**
299     * Purge all caches
300     */
301    public static function purgeCache()
302    {
303        // expire dokuwiki caches
304        // touching local.php expires wiki page, JS and CSS caches
305        global $config_cascade;
306        @touch(reset($config_cascade['main']['local']));
307
308        if (function_exists('opcache_reset')) {
309            opcache_reset();
310        }
311    }
312
313    /**
314     * Get the list of processed extensions and their status during an installation run
315     *
316     * @return array id => status
317     */
318    public function getProcessed()
319    {
320        return $this->processed;
321    }
322
323
324    /**
325     * Ensure that the given extension is compatible with the current PHP version
326     *
327     * Throws an exception if the extension is not compatible
328     *
329     * @param Extension $extension
330     * @throws Exception
331     */
332    public static function ensurePhpCompatibility(Extension $extension)
333    {
334        $min = $extension->getMinimumPHPVersion();
335        if ($min && version_compare(PHP_VERSION, $min, '<')) {
336            throw new Exception('error_minphp', [$extension->getId(), $min, PHP_VERSION]);
337        }
338
339        $max = $extension->getMaximumPHPVersion();
340        if ($max && version_compare(PHP_VERSION, $max, '>')) {
341            throw new Exception('error_maxphp', [$extension->getId(), $max, PHP_VERSION]);
342        }
343    }
344
345    /**
346     * Ensure the file permissions are correct before attempting to install
347     *
348     * @throws Exception if the permissions are not correct
349     */
350    public static function ensurePermissions(Extension $extension)
351    {
352        $target = $extension->getInstallDir();
353
354        // updates
355        if (file_exists($target)) {
356            if (!is_writable($target)) throw new Exception('noperms');
357            return;
358        }
359
360        // new installs
361        $target = dirname($target);
362        if (!is_writable($target)) {
363            if ($extension->isTemplate()) throw new Exception('notplperms');
364            throw new Exception('nopluginperms');
365        }
366    }
367
368    /**
369     * Get a base name from an archive name (we don't trust)
370     *
371     * @param string $file
372     * @return string
373     */
374    protected function fileToBase($file)
375    {
376        $base = PhpString::basename($file);
377        $base = preg_replace('/\.(tar\.gz|tar\.bz|tar\.bz2|tar|tgz|tbz|zip)$/', '', $base);
378        return preg_replace('/\W+/', '', $base);
379    }
380
381    /**
382     * Returns a temporary directory
383     *
384     * The directory is registered for cleanup when the class is destroyed
385     *
386     * @return string
387     * @throws Exception
388     */
389    protected function mkTmpDir()
390    {
391        try {
392            $dir = io_mktmpdir();
393        } catch (\Exception $e) {
394            throw new Exception('error_dircreate', [], $e);
395        }
396        if (!$dir) throw new Exception('error_dircreate');
397        $this->temporary[] = $dir;
398        return $dir;
399    }
400
401    /**
402     * Find all extensions in a given directory
403     *
404     * This allows us to install extensions from archives that contain multiple extensions and
405     * also caters for the fact that archives may or may not contain subdirectories for the extension(s).
406     *
407     * @param string $dir
408     * @return Extension[]
409     */
410    protected function findExtensions($dir, $base = null)
411    {
412        // first check for plugin.info.txt or template.info.txt
413        $extensions = [];
414        $iterator = new RecursiveDirectoryIterator($dir);
415        foreach (new RecursiveIteratorIterator($iterator) as $file) {
416            if (
417                $file->getFilename() === 'plugin.info.txt' ||
418                $file->getFilename() === 'template.info.txt'
419            ) {
420                $extensions[] = Extension::createFromDirectory($file->getPath());
421            }
422        }
423        if ($extensions) return $extensions;
424
425        // still nothing? we assume this to be a single extension that is either
426        // directly in the given directory or in single subdirectory
427        $files = glob($dir . '/*');
428        if (count($files) === 1 && is_dir($files[0])) {
429            $dir = $files[0];
430        }
431        $base ??= PhpString::basename($dir);
432        return [Extension::createFromDirectory($dir, null, $base)];
433    }
434
435    /**
436     * Extract the given archive to the given target directory
437     *
438     * Auto-guesses the archive type
439     * @throws Exception
440     */
441    protected function extractArchive($archive, $target)
442    {
443        $fh = fopen($archive, 'rb');
444        if (!$fh) throw new Exception('error_archive_read', [$archive]);
445        $magic = fread($fh, 5);
446        fclose($fh);
447
448        if (strpos($magic, "\x50\x4b\x03\x04") === 0) {
449            $archiver = new Zip();
450        } else {
451            $archiver = new Tar();
452        }
453        try {
454            $archiver->open($archive);
455            $archiver->extract($target);
456        } catch (ArchiveIOException | ArchiveCorruptedException | ArchiveIllegalCompressionException $e) {
457            throw new Exception('error_archive_extract', [$archive, $e->getMessage()], $e);
458        }
459    }
460
461    /**
462     * Copy with recursive sub-directory support
463     *
464     * @param string $src filename path to file
465     * @param string $dst filename path to file
466     * @throws Exception
467     */
468    protected function dircopy($src, $dst)
469    {
470        global $conf;
471
472        if (is_dir($src)) {
473            if (!$dh = @opendir($src)) {
474                throw new Exception('error_copy_read', [$src]);
475            }
476
477            if (io_mkdir_p($dst)) {
478                while (false !== ($f = readdir($dh))) {
479                    if ($f == '..' || $f == '.') continue;
480                    $this->dircopy("$src/$f", "$dst/$f");
481                }
482            } else {
483                throw new Exception('error_copy_mkdir', [$dst]);
484            }
485
486            closedir($dh);
487        } else {
488            $existed = file_exists($dst);
489
490            if (!@copy($src, $dst)) {
491                throw new Exception('error_copy_copy', [$src, $dst]);
492            }
493            if (!$existed && $conf['fperm']) chmod($dst, $conf['fperm']);
494            @touch($dst, filemtime($src));
495        }
496    }
497
498    /**
499     * Reset caches if needed
500     */
501    protected function cleanUp()
502    {
503        if ($this->isDirty) {
504            self::purgeCache();
505            $this->isDirty = false;
506        }
507    }
508}
509