xref: /dokuwiki/lib/plugins/extension/Installer.php (revision 8fe483c9dd38f0052abe729cc74057c9cdf54ad3)
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 (preg_match(
252            '/attachment;\s*filename\s*=\s*"([^"]*)"/i',
253            (string)($http->resp_headers['content-disposition'] ?? ''),
254            $match
255        )) {
256            $file = PhpString::basename($match[1]);
257        }
258
259        // clean up filename
260        $file = $this->fileToBase($file);
261
262        // create tmp directory for download
263        $tmp = $this->mkTmpDir();
264
265        // save the file
266        if (@file_put_contents("$tmp/$file", $data) === false) {
267            throw new Exception('error_save');
268        }
269
270        return "$tmp/$file";
271    }
272
273
274    /**
275     * Delete outdated files
276     */
277    public function removeDeletedFiles(Extension $extension)
278    {
279        $extensiondir = $extension->getInstallDir();
280        $definitionfile = $extensiondir . '/deleted.files';
281        if (!file_exists($definitionfile)) return;
282
283        $list = file($definitionfile);
284        foreach ($list as $line) {
285            $line = trim(preg_replace('/#.*$/', '', $line));
286            $line = str_replace('..', '', $line); // do not run out of the extension directory
287            if (!$line) continue;
288
289            $file = $extensiondir . '/' . $line;
290            if (!file_exists($file)) continue;
291
292            io_rmdir($file, true);
293        }
294    }
295
296    /**
297     * Purge all caches
298     */
299    public static function purgeCache()
300    {
301        // expire dokuwiki caches
302        // touching local.php expires wiki page, JS and CSS caches
303        global $config_cascade;
304        @touch(reset($config_cascade['main']['local']));
305
306        if (function_exists('opcache_reset')) {
307            opcache_reset();
308        }
309    }
310
311    /**
312     * Get the list of processed extensions and their status during an installation run
313     *
314     * @return array id => status
315     */
316    public function getProcessed()
317    {
318        return $this->processed;
319    }
320
321
322    /**
323     * Ensure that the given extension is compatible with the current PHP version
324     *
325     * Throws an exception if the extension is not compatible
326     *
327     * @param Extension $extension
328     * @throws Exception
329     */
330    public static function ensurePhpCompatibility(Extension $extension)
331    {
332        $min = $extension->getMinimumPHPVersion();
333        if ($min && version_compare(PHP_VERSION, $min, '<')) {
334            throw new Exception('error_minphp', [$extension->getId(), $min, PHP_VERSION]);
335        }
336
337        $max = $extension->getMaximumPHPVersion();
338        if ($max && version_compare(PHP_VERSION, $max, '>')) {
339            throw new Exception('error_maxphp', [$extension->getId(), $max, PHP_VERSION]);
340        }
341    }
342
343    /**
344     * Ensure the file permissions are correct before attempting to install
345     *
346     * @throws Exception if the permissions are not correct
347     */
348    public static function ensurePermissions(Extension $extension)
349    {
350        $target = $extension->getInstallDir();
351
352        // updates
353        if (file_exists($target)) {
354            if (!is_writable($target)) throw new Exception('noperms');
355            return;
356        }
357
358        // new installs
359        $target = dirname($target);
360        if (!is_writable($target)) {
361            if ($extension->isTemplate()) throw new Exception('notplperms');
362            throw new Exception('nopluginperms');
363        }
364    }
365
366    /**
367     * Get a base name from an archive name (we don't trust)
368     *
369     * @param string $file
370     * @return string
371     */
372    protected function fileToBase($file)
373    {
374        $base = PhpString::basename($file);
375        $base = preg_replace('/\.(tar\.gz|tar\.bz|tar\.bz2|tar|tgz|tbz|zip)$/', '', $base);
376        return preg_replace('/\W+/', '', $base);
377    }
378
379    /**
380     * Returns a temporary directory
381     *
382     * The directory is registered for cleanup when the class is destroyed
383     *
384     * @return string
385     * @throws Exception
386     */
387    protected function mkTmpDir()
388    {
389        try {
390            $dir = io_mktmpdir();
391        } catch (\Exception $e) {
392            throw new Exception('error_dircreate', [], $e);
393        }
394        if (!$dir) throw new Exception('error_dircreate');
395        $this->temporary[] = $dir;
396        return $dir;
397    }
398
399    /**
400     * Find all extensions in a given directory
401     *
402     * This allows us to install extensions from archives that contain multiple extensions and
403     * also caters for the fact that archives may or may not contain subdirectories for the extension(s).
404     *
405     * @param string $dir
406     * @return Extension[]
407     */
408    protected function findExtensions($dir, $base = null)
409    {
410        // first check for plugin.info.txt or template.info.txt
411        $extensions = [];
412        $iterator = new RecursiveDirectoryIterator($dir);
413        foreach (new RecursiveIteratorIterator($iterator) as $file) {
414            if (
415                $file->getFilename() === 'plugin.info.txt' ||
416                $file->getFilename() === 'template.info.txt'
417            ) {
418                $extensions[] = Extension::createFromDirectory($file->getPath());
419            }
420        }
421        if ($extensions) return $extensions;
422
423        // still nothing? we assume this to be a single extension that is either
424        // directly in the given directory or in single subdirectory
425        $files = glob($dir . '/*');
426        if (count($files) === 1 && is_dir($files[0])) {
427            $dir = $files[0];
428        }
429        $base = $base ?? PhpString::basename($dir);
430        return [Extension::createFromDirectory($dir, null, $base)];
431    }
432
433    /**
434     * Extract the given archive to the given target directory
435     *
436     * Auto-guesses the archive type
437     * @throws Exception
438     */
439    protected function extractArchive($archive, $target)
440    {
441        $fh = fopen($archive, 'rb');
442        if (!$fh) throw new Exception('error_archive_read', [$archive]);
443        $magic = fread($fh, 5);
444        fclose($fh);
445
446        if (strpos($magic, "\x50\x4b\x03\x04") === 0) {
447            $archiver = new Zip();
448        } else {
449            $archiver = new Tar();
450        }
451        try {
452            $archiver->open($archive);
453            $archiver->extract($target);
454        } catch (ArchiveIOException|ArchiveCorruptedException|ArchiveIllegalCompressionException $e) {
455            throw new Exception('error_archive_extract', [$archive, $e->getMessage()], $e);
456        }
457    }
458
459    /**
460     * Copy with recursive sub-directory support
461     *
462     * @param string $src filename path to file
463     * @param string $dst filename path to file
464     * @throws Exception
465     */
466    protected function dircopy($src, $dst)
467    {
468        global $conf;
469
470        if (is_dir($src)) {
471            if (!$dh = @opendir($src)) {
472                throw new Exception('error_copy_read', [$src]);
473            }
474
475            if (io_mkdir_p($dst)) {
476                while (false !== ($f = readdir($dh))) {
477                    if ($f == '..' || $f == '.') continue;
478                    $this->dircopy("$src/$f", "$dst/$f");
479                }
480            } else {
481                throw new Exception('error_copy_mkdir', [$dst]);
482            }
483
484            closedir($dh);
485        } else {
486            $existed = file_exists($dst);
487
488            if (!@copy($src, $dst)) {
489                throw new Exception('error_copy_copy', [$src, $dst]);
490            }
491            if (!$existed && $conf['fperm']) chmod($dst, $conf['fperm']);
492            @touch($dst, filemtime($src));
493        }
494    }
495
496    /**
497     * Reset caches if needed
498     */
499    protected function cleanUp()
500    {
501        if ($this->isDirty) {
502            self::purgeCache();
503            $this->isDirty = false;
504        }
505    }
506}
507