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