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