xref: /dokuwiki/lib/plugins/extension/Installer.php (revision b2a05b76de6c1d1e38212dff43776aaa41a22894)
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            $this->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            $this->dircopy(
179                $extension->getCurrentDir(),
180                $extension->getInstallDir()
181            );
182            $this->isDirty = true;
183            $extension->getManager()->storeUpdate($this->sourceUrl);
184            $this->removeDeletedFiles($extension);
185            $this->processed[$extension->getId()] = $status;
186        }
187
188        $this->cleanUp();
189    }
190
191    /**
192     * Uninstall an extension
193     *
194     * @param Extension $extension
195     * @throws Exception
196     */
197    public function uninstall(Extension $extension)
198    {
199        // FIXME check if dependencies are still needed
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        if (!io_rmdir($extension->getInstallDir(), true)) {
210            throw new Exception('msg_delete_failed', [$extension->getId()]);
211        }
212        self::purgeCache();
213    }
214
215    /**
216     * Download an archive to a protected path
217     *
218     * @param string $url The url to get the archive from
219     * @return string The path where the archive was saved
220     * @throws Exception
221     */
222    public function downloadArchive($url)
223    {
224        // check the url
225        if (!preg_match('/https?:\/\//i', $url)) {
226            throw new Exception('error_badurl');
227        }
228
229        // try to get the file from the path (used as plugin name fallback)
230        $file = parse_url($url, PHP_URL_PATH);
231        $file = $file ? PhpString::basename($file) : md5($url);
232
233        // download
234        $http = new DokuHTTPClient();
235        $http->max_bodysize = 0;
236        $http->timeout = 25; //max. 25 sec
237        $http->keep_alive = false; // we do single ops here, no need for keep-alive
238        $http->agent = 'DokuWiki HTTP Client (Extension Manager)';
239
240        $data = $http->get($url);
241        if ($data === false) throw new Exception('error_download', [$url, $http->error, $http->status]);
242
243        // get filename from headers
244        if (preg_match(
245            '/attachment;\s*filename\s*=\s*"([^"]*)"/i',
246            (string)($http->resp_headers['content-disposition'] ?? ''),
247            $match
248        )) {
249            $file = PhpString::basename($match[1]);
250        }
251
252        // clean up filename
253        $file = $this->fileToBase($file);
254
255        // create tmp directory for download
256        $tmp = $this->mkTmpDir();
257
258        // save the file
259        if (@file_put_contents("$tmp/$file", $data) === false) {
260            throw new Exception('error_save');
261        }
262
263        return "$tmp/$file";
264    }
265
266
267    /**
268     * Delete outdated files
269     */
270    public function removeDeletedFiles(Extension $extension)
271    {
272        $extensiondir = $extension->getInstallDir();
273        $definitionfile = $extensiondir . '/deleted.files';
274        if (!file_exists($definitionfile)) return;
275
276        $list = file($definitionfile);
277        foreach ($list as $line) {
278            $line = trim(preg_replace('/#.*$/', '', $line));
279            $line = str_replace('..', '', $line); // do not run out of the extension directory
280            if (!$line) continue;
281
282            $file = $extensiondir . '/' . $line;
283            if (!file_exists($file)) continue;
284
285            io_rmdir($file, true);
286        }
287    }
288
289    /**
290     * Purge all caches
291     */
292    public static function purgeCache()
293    {
294        // expire dokuwiki caches
295        // touching local.php expires wiki page, JS and CSS caches
296        global $config_cascade;
297        @touch(reset($config_cascade['main']['local']));
298
299        if (function_exists('opcache_reset')) {
300            opcache_reset();
301        }
302    }
303
304    /**
305     * Get the list of processed extensions and their status during an installation run
306     *
307     * @return array id => status
308     */
309    public function getProcessed()
310    {
311        return $this->processed;
312    }
313
314
315    /**
316     * Ensure that the given extension is compatible with the current PHP version
317     *
318     * Throws an exception if the extension is not compatible
319     *
320     * @param Extension $extension
321     * @throws Exception
322     */
323    protected function ensurePhpCompatibility(Extension $extension)
324    {
325        $min = $extension->getMinimumPHPVersion();
326        if ($min && version_compare(PHP_VERSION, $min, '<')) {
327            throw new Exception('error_minphp', [$extension->getId(), $min, PHP_VERSION]);
328        }
329
330        $max = $extension->getMaximumPHPVersion();
331        if ($max && version_compare(PHP_VERSION, $max, '>')) {
332            throw new Exception('error_maxphp', [$extension->getId(), $max, PHP_VERSION]);
333        }
334    }
335
336
337    /**
338     * Get a base name from an archive name (we don't trust)
339     *
340     * @param string $file
341     * @return string
342     */
343    protected function fileToBase($file)
344    {
345        $base = PhpString::basename($file);
346        $base = preg_replace('/\.(tar\.gz|tar\.bz|tar\.bz2|tar|tgz|tbz|zip)$/', '', $base);
347        return preg_replace('/\W+/', '', $base);
348    }
349
350    /**
351     * Returns a temporary directory
352     *
353     * The directory is registered for cleanup when the class is destroyed
354     *
355     * @return string
356     * @throws Exception
357     */
358    protected function mkTmpDir()
359    {
360        try {
361            $dir = io_mktmpdir();
362        } catch (\Exception $e) {
363            throw new Exception('error_dircreate', [], $e);
364        }
365        if (!$dir) throw new Exception('error_dircreate');
366        $this->temporary[] = $dir;
367        return $dir;
368    }
369
370    /**
371     * Find all extensions in a given directory
372     *
373     * This allows us to install extensions from archives that contain multiple extensions and
374     * also caters for the fact that archives may or may not contain subdirectories for the extension(s).
375     *
376     * @param string $dir
377     * @return Extension[]
378     */
379    protected function findExtensions($dir, $base = null)
380    {
381        // first check for plugin.info.txt or template.info.txt
382        $extensions = [];
383        $iterator = new RecursiveDirectoryIterator($dir);
384        foreach (new RecursiveIteratorIterator($iterator) as $file) {
385            if (
386                $file->getFilename() === 'plugin.info.txt' ||
387                $file->getFilename() === 'template.info.txt'
388            ) {
389                $extensions[] = Extension::createFromDirectory($file->getPath());
390            }
391        }
392        if ($extensions) return $extensions;
393
394        // still nothing? we assume this to be a single extension that is either
395        // directly in the given directory or in single subdirectory
396        $base = $base ?? PhpString::basename($dir);
397        $files = glob($dir . '/*');
398        if (count($files) === 1 && is_dir($files[0])) {
399            $dir = $files[0];
400        }
401        return [Extension::createFromDirectory($dir, null, $base)];
402    }
403
404    /**
405     * Extract the given archive to the given target directory
406     *
407     * Auto-guesses the archive type
408     * @throws Exception
409     */
410    protected function extractArchive($archive, $target)
411    {
412        $fh = fopen($archive, 'rb');
413        if (!$fh) throw new Exception('error_archive_read', [$archive]);
414        $magic = fread($fh, 5);
415        fclose($fh);
416
417        if (strpos($magic, "\x50\x4b\x03\x04") === 0) {
418            $archiver = new Zip();
419        } else {
420            $archiver = new Tar();
421        }
422        try {
423            $archiver->open($archive);
424            $archiver->extract($target);
425        } catch (ArchiveIOException|ArchiveCorruptedException|ArchiveIllegalCompressionException $e) {
426            throw new Exception('error_archive_extract', [$archive, $e->getMessage()], $e);
427        }
428    }
429
430    /**
431     * Copy with recursive sub-directory support
432     *
433     * @param string $src filename path to file
434     * @param string $dst filename path to file
435     * @throws Exception
436     */
437    protected function dircopy($src, $dst)
438    {
439        global $conf;
440
441        if (is_dir($src)) {
442            if (!$dh = @opendir($src)) {
443                throw new Exception('error_copy_read', [$src]);
444            }
445
446            if (io_mkdir_p($dst)) {
447                while (false !== ($f = readdir($dh))) {
448                    if ($f == '..' || $f == '.') continue;
449                    $this->dircopy("$src/$f", "$dst/$f");
450                }
451            } else {
452                throw new Exception('error_copy_mkdir', [$dst]);
453            }
454
455            closedir($dh);
456        } else {
457            $existed = file_exists($dst);
458
459            if (!@copy($src, $dst)) {
460                throw new Exception('error_copy_copy', [$src, $dst]);
461            }
462            if (!$existed && $conf['fperm']) chmod($dst, $conf['fperm']);
463            @touch($dst, filemtime($src));
464        }
465    }
466
467    /**
468     * Reset caches if needed
469     */
470    protected function cleanUp()
471    {
472        if ($this->isDirty) {
473            self::purgeCache();
474            $this->isDirty = false;
475        }
476    }
477}
478