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