xref: /dokuwiki/lib/plugins/extension/Installer.php (revision a1ef4d6260401c454faedc5d90cb3887bb07a19c)
1cf2dcf1bSAndreas Gohr<?php
2cf2dcf1bSAndreas Gohr
3cf2dcf1bSAndreas Gohrnamespace dokuwiki\plugin\extension;
4cf2dcf1bSAndreas Gohr
5b69d74f1SAndreas Gohruse dokuwiki\Extension\PluginController;
6cf2dcf1bSAndreas Gohruse dokuwiki\HTTP\DokuHTTPClient;
7cf2dcf1bSAndreas Gohruse dokuwiki\Utf8\PhpString;
8cf2dcf1bSAndreas Gohruse RecursiveDirectoryIterator;
9cf2dcf1bSAndreas Gohruse RecursiveIteratorIterator;
10cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\ArchiveCorruptedException;
11cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\ArchiveIllegalCompressionException;
12cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\ArchiveIOException;
13cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\Tar;
14cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\Zip;
15cf2dcf1bSAndreas Gohr
16cf2dcf1bSAndreas Gohr/**
17cf2dcf1bSAndreas Gohr * Install and deinstall extensions
18cf2dcf1bSAndreas Gohr *
19cf2dcf1bSAndreas Gohr * This manages all the file operations and downloads needed to install an extension.
20cf2dcf1bSAndreas Gohr */
21cf2dcf1bSAndreas Gohrclass Installer
22cf2dcf1bSAndreas Gohr{
23cf2dcf1bSAndreas Gohr    /** @var string[] a list of temporary directories used during this installation */
24cf2dcf1bSAndreas Gohr    protected array $temporary = [];
25cf2dcf1bSAndreas Gohr
26cf2dcf1bSAndreas Gohr    /** @var bool if changes have been made that require a cache purge */
27cf2dcf1bSAndreas Gohr    protected $isDirty = false;
28cf2dcf1bSAndreas Gohr
29cf2dcf1bSAndreas Gohr    /** @var bool Replace existing files? */
30cf2dcf1bSAndreas Gohr    protected $overwrite = false;
31cf2dcf1bSAndreas Gohr
32cf2dcf1bSAndreas Gohr    /** @var string The last used URL to install an extension */
33cf2dcf1bSAndreas Gohr    protected $sourceUrl = '';
34cf2dcf1bSAndreas Gohr
3525d28a01SAndreas Gohr    protected $processed = [];
3625d28a01SAndreas Gohr
3725d28a01SAndreas Gohr    public const STATUS_SKIPPED = 'skipped';
3825d28a01SAndreas Gohr    public const STATUS_UPDATED = 'updated';
3925d28a01SAndreas Gohr    public const STATUS_INSTALLED = 'installed';
4080bc92fbSAndreas Gohr    public const STATUS_REMOVED = 'removed';
4125d28a01SAndreas Gohr
42cf2dcf1bSAndreas Gohr    /**
43cf2dcf1bSAndreas Gohr     * Initialize a new extension installer
44cf2dcf1bSAndreas Gohr     *
45cf2dcf1bSAndreas Gohr     * @param bool $overwrite
46cf2dcf1bSAndreas Gohr     */
47cf2dcf1bSAndreas Gohr    public function __construct($overwrite = false)
48cf2dcf1bSAndreas Gohr    {
49cf2dcf1bSAndreas Gohr        $this->overwrite = $overwrite;
50cf2dcf1bSAndreas Gohr    }
51cf2dcf1bSAndreas Gohr
52cf2dcf1bSAndreas Gohr    /**
53cf2dcf1bSAndreas Gohr     * Destructor
54cf2dcf1bSAndreas Gohr     *
55cf2dcf1bSAndreas Gohr     * deletes any dangling temporary directories
56cf2dcf1bSAndreas Gohr     */
57cf2dcf1bSAndreas Gohr    public function __destruct()
58cf2dcf1bSAndreas Gohr    {
5925d28a01SAndreas Gohr        foreach ($this->temporary as $dir) {
6025d28a01SAndreas Gohr            io_rmdir($dir, true);
6125d28a01SAndreas Gohr        }
62cf2dcf1bSAndreas Gohr        $this->cleanUp();
63cf2dcf1bSAndreas Gohr    }
64cf2dcf1bSAndreas Gohr
65cf2dcf1bSAndreas Gohr    /**
66160d3688SAndreas Gohr     * Install an extension by ID
6725d28a01SAndreas Gohr     *
68160d3688SAndreas Gohr     * This will simply call installExtension after constructing an extension from the ID
6925d28a01SAndreas Gohr     *
7025d28a01SAndreas Gohr     * The $skipInstalled parameter should only be used when installing dependencies
7125d28a01SAndreas Gohr     *
7225d28a01SAndreas Gohr     * @param string $id the extension ID
7325d28a01SAndreas Gohr     * @param bool $skipInstalled Ignore the overwrite setting and skip installed extensions
7425d28a01SAndreas Gohr     * @throws Exception
7525d28a01SAndreas Gohr     */
76160d3688SAndreas Gohr    public function installFromId($id, $skipInstalled = false)
77160d3688SAndreas Gohr    {
7825d28a01SAndreas Gohr        $extension = Extension::createFromId($id);
7925d28a01SAndreas Gohr        if ($skipInstalled && $extension->isInstalled()) return;
80160d3688SAndreas Gohr        $this->installExtension($extension);
81160d3688SAndreas Gohr    }
82160d3688SAndreas Gohr
83160d3688SAndreas Gohr    /**
84160d3688SAndreas Gohr     * Install an extension
85160d3688SAndreas Gohr     *
86160d3688SAndreas Gohr     * This will simply call installFromUrl() with the URL from the extension
87160d3688SAndreas Gohr     *
88160d3688SAndreas Gohr     * @param Extension $extension
89160d3688SAndreas Gohr     * @throws Exception
90160d3688SAndreas Gohr     */
91160d3688SAndreas Gohr    public function installExtension(Extension $extension)
92160d3688SAndreas Gohr    {
9325d28a01SAndreas Gohr        $url = $extension->getDownloadURL();
9425d28a01SAndreas Gohr        if (!$url) {
9525d28a01SAndreas Gohr            throw new Exception('error_nourl', [$extension->getId()]);
9625d28a01SAndreas Gohr        }
97*a1ef4d62SAndreas Gohr        $this->installFromUrl($url, $extension->getBase());
9825d28a01SAndreas Gohr    }
9925d28a01SAndreas Gohr
10025d28a01SAndreas Gohr    /**
101cf2dcf1bSAndreas Gohr     * Install extensions from a given URL
102cf2dcf1bSAndreas Gohr     *
103cf2dcf1bSAndreas Gohr     * @param string $url the URL to the archive
104cf2dcf1bSAndreas Gohr     * @param null $base the base directory name to use
105cf2dcf1bSAndreas Gohr     * @throws Exception
106cf2dcf1bSAndreas Gohr     */
107cf2dcf1bSAndreas Gohr    public function installFromUrl($url, $base = null)
108cf2dcf1bSAndreas Gohr    {
109cf2dcf1bSAndreas Gohr        $this->sourceUrl = $url;
110cf2dcf1bSAndreas Gohr        $archive = $this->downloadArchive($url);
111cf2dcf1bSAndreas Gohr        $this->installFromArchive(
112cf2dcf1bSAndreas Gohr            $archive,
113cf2dcf1bSAndreas Gohr            $base
114cf2dcf1bSAndreas Gohr        );
115cf2dcf1bSAndreas Gohr    }
116cf2dcf1bSAndreas Gohr
117cf2dcf1bSAndreas Gohr    /**
118cf2dcf1bSAndreas Gohr     * Install extensions from a user upload
119cf2dcf1bSAndreas Gohr     *
120cf2dcf1bSAndreas Gohr     * @param string $field name of the upload file
121cf2dcf1bSAndreas Gohr     * @throws Exception
122cf2dcf1bSAndreas Gohr     */
123cf2dcf1bSAndreas Gohr    public function installFromUpload($field)
124cf2dcf1bSAndreas Gohr    {
125cf2dcf1bSAndreas Gohr        $this->sourceUrl = '';
126cf2dcf1bSAndreas Gohr        if ($_FILES[$field]['error']) {
127cf2dcf1bSAndreas Gohr            throw new Exception('msg_upload_failed', [$_FILES[$field]['error']]);
128cf2dcf1bSAndreas Gohr        }
129cf2dcf1bSAndreas Gohr
130*a1ef4d62SAndreas Gohr        $tmpbase = $this->fileToBase($_FILES[$field]['name']) ?: 'upload';
131cf2dcf1bSAndreas Gohr        $tmp = $this->mkTmpDir();
132*a1ef4d62SAndreas Gohr        if (!move_uploaded_file($_FILES[$field]['tmp_name'], "$tmp/$tmpbase.archive")) {
133cf2dcf1bSAndreas Gohr            throw new Exception('msg_upload_failed', ['move failed']);
134cf2dcf1bSAndreas Gohr        }
135*a1ef4d62SAndreas Gohr        $this->installFromArchive("$tmp/$tmpbase.archive");
136cf2dcf1bSAndreas Gohr    }
137cf2dcf1bSAndreas Gohr
138cf2dcf1bSAndreas Gohr    /**
139cf2dcf1bSAndreas Gohr     * Install extensions from an archive
140cf2dcf1bSAndreas Gohr     *
141cf2dcf1bSAndreas Gohr     * The archive is extracted to a temporary directory and then the contained extensions are installed.
142cf2dcf1bSAndreas Gohr     * This is is the ultimate installation procedure and all other install methods will end up here.
143cf2dcf1bSAndreas Gohr     *
144cf2dcf1bSAndreas Gohr     * @param string $archive the path to the archive
145cf2dcf1bSAndreas Gohr     * @param string $base the base directory name to use
146cf2dcf1bSAndreas Gohr     * @throws Exception
147cf2dcf1bSAndreas Gohr     */
148cf2dcf1bSAndreas Gohr    public function installFromArchive($archive, $base = null)
149cf2dcf1bSAndreas Gohr    {
150*a1ef4d62SAndreas Gohr        $target = $this->mkTmpDir() . '/' . ($base ?? $this->fileToBase($archive));
151cf2dcf1bSAndreas Gohr        $this->extractArchive($archive, $target);
152cf2dcf1bSAndreas Gohr        $extensions = $this->findExtensions($target, $base);
153cf2dcf1bSAndreas Gohr        foreach ($extensions as $extension) {
15425d28a01SAndreas Gohr            // check installation status
15525d28a01SAndreas Gohr            if ($extension->isInstalled()) {
15625d28a01SAndreas Gohr                if (!$this->overwrite) {
15725d28a01SAndreas Gohr                    $this->processed[$extension->getId()] = self::STATUS_SKIPPED;
158cf2dcf1bSAndreas Gohr                    continue;
159cf2dcf1bSAndreas Gohr                }
16025d28a01SAndreas Gohr                $status = self::STATUS_UPDATED;
16125d28a01SAndreas Gohr            } else {
16225d28a01SAndreas Gohr                $status = self::STATUS_INSTALLED;
16325d28a01SAndreas Gohr            }
164cf2dcf1bSAndreas Gohr
165b2a05b76SAndreas Gohr            // check PHP requirements
1664fd6a1d7SAndreas Gohr            self::ensurePhpCompatibility($extension);
16725d28a01SAndreas Gohr
16825d28a01SAndreas Gohr            // install dependencies first
16925d28a01SAndreas Gohr            foreach ($extension->getDependencyList() as $id) {
17025d28a01SAndreas Gohr                if (isset($this->processed[$id])) continue;
17125d28a01SAndreas Gohr                if ($id == $extension->getId()) continue; // avoid circular dependencies
17225d28a01SAndreas Gohr                $this->installFromId($id, true);
17325d28a01SAndreas Gohr            }
17425d28a01SAndreas Gohr
17525d28a01SAndreas Gohr            // now install the extension
1764fd6a1d7SAndreas Gohr            self::ensurePermissions($extension);
177cf2dcf1bSAndreas Gohr            $this->dircopy(
178cf2dcf1bSAndreas Gohr                $extension->getCurrentDir(),
179cf2dcf1bSAndreas Gohr                $extension->getInstallDir()
180cf2dcf1bSAndreas Gohr            );
181cf2dcf1bSAndreas Gohr            $this->isDirty = true;
1827c9966a5SAndreas Gohr            $extension->getManager()->storeUpdate($this->sourceUrl);
183cf2dcf1bSAndreas Gohr            $this->removeDeletedFiles($extension);
18425d28a01SAndreas Gohr            $this->processed[$extension->getId()] = $status;
185cf2dcf1bSAndreas Gohr        }
186cf2dcf1bSAndreas Gohr
187cf2dcf1bSAndreas Gohr        $this->cleanUp();
188cf2dcf1bSAndreas Gohr    }
189cf2dcf1bSAndreas Gohr
190cf2dcf1bSAndreas Gohr    /**
191cf2dcf1bSAndreas Gohr     * Uninstall an extension
192cf2dcf1bSAndreas Gohr     *
193cf2dcf1bSAndreas Gohr     * @param Extension $extension
194cf2dcf1bSAndreas Gohr     * @throws Exception
195cf2dcf1bSAndreas Gohr     */
196cf2dcf1bSAndreas Gohr    public function uninstall(Extension $extension)
197cf2dcf1bSAndreas Gohr    {
198160d3688SAndreas Gohr        if (!$extension->isInstalled()) {
199160d3688SAndreas Gohr            throw new Exception('error_notinstalled', [$extension->getId()]);
200160d3688SAndreas Gohr        }
201160d3688SAndreas Gohr
202cf2dcf1bSAndreas Gohr        if ($extension->isProtected()) {
203cf2dcf1bSAndreas Gohr            throw new Exception('error_uninstall_protected', [$extension->getId()]);
204cf2dcf1bSAndreas Gohr        }
205cf2dcf1bSAndreas Gohr
2064fd6a1d7SAndreas Gohr        self::ensurePermissions($extension);
2074fd6a1d7SAndreas Gohr
208b69d74f1SAndreas Gohr        $dependants = $extension->getDependants();
209b69d74f1SAndreas Gohr        if ($dependants !== []) {
210b69d74f1SAndreas Gohr            throw new Exception('error_uninstall_dependants', [$extension->getId(), implode(', ', $dependants)]);
211b69d74f1SAndreas Gohr        }
212b69d74f1SAndreas Gohr
213cf2dcf1bSAndreas Gohr        if (!io_rmdir($extension->getInstallDir(), true)) {
214cf2dcf1bSAndreas Gohr            throw new Exception('msg_delete_failed', [$extension->getId()]);
215cf2dcf1bSAndreas Gohr        }
216cf2dcf1bSAndreas Gohr        self::purgeCache();
21780bc92fbSAndreas Gohr
21880bc92fbSAndreas Gohr        $this->processed[$extension->getId()] = self::STATUS_REMOVED;
219cf2dcf1bSAndreas Gohr    }
220cf2dcf1bSAndreas Gohr
221cf2dcf1bSAndreas Gohr    /**
222b69d74f1SAndreas Gohr     * Enable the extension
223b69d74f1SAndreas Gohr     *
224b69d74f1SAndreas Gohr     * @throws Exception
225b69d74f1SAndreas Gohr     */
226b69d74f1SAndreas Gohr    public function enable(Extension $extension)
227b69d74f1SAndreas Gohr    {
228b69d74f1SAndreas Gohr        if ($extension->isTemplate()) throw new Exception('notimplemented');
229b69d74f1SAndreas Gohr        if (!$extension->isInstalled()) throw new Exception('error_notinstalled', [$extension->getId()]);
230b69d74f1SAndreas Gohr        if ($extension->isEnabled()) throw new Exception('error_alreadyenabled', [$extension->getId()]);
231b69d74f1SAndreas Gohr
232b69d74f1SAndreas Gohr        /* @var PluginController $plugin_controller */
233b69d74f1SAndreas Gohr        global $plugin_controller;
234b69d74f1SAndreas Gohr        if (!$plugin_controller->enable($extension->getBase())) {
235b69d74f1SAndreas Gohr            throw new Exception('pluginlistsaveerror');
236b69d74f1SAndreas Gohr        }
237b69d74f1SAndreas Gohr        self::purgeCache();
238b69d74f1SAndreas Gohr    }
239b69d74f1SAndreas Gohr
240b69d74f1SAndreas Gohr    /**
241b69d74f1SAndreas Gohr     * Disable the extension
242b69d74f1SAndreas Gohr     *
243b69d74f1SAndreas Gohr     * @throws Exception
244b69d74f1SAndreas Gohr     */
245b69d74f1SAndreas Gohr    public function disable(Extension $extension)
246b69d74f1SAndreas Gohr    {
247b69d74f1SAndreas Gohr        if ($extension->isTemplate()) throw new Exception('notimplemented');
248b69d74f1SAndreas Gohr        if (!$extension->isInstalled()) throw new Exception('error_notinstalled', [$extension->getId()]);
249b69d74f1SAndreas Gohr        if (!$extension->isEnabled()) throw new Exception('error_alreadydisabled', [$extension->getId()]);
250b69d74f1SAndreas Gohr        if ($extension->isProtected()) throw new Exception('error_disable_protected', [$extension->getId()]);
251b69d74f1SAndreas Gohr
252b69d74f1SAndreas Gohr        $dependants = $extension->getDependants();
253b69d74f1SAndreas Gohr        if ($dependants !== []) {
254b69d74f1SAndreas Gohr            throw new Exception('error_disable_dependants', [$extension->getId(), implode(', ', $dependants)]);
255b69d74f1SAndreas Gohr        }
256b69d74f1SAndreas Gohr
257b69d74f1SAndreas Gohr        /* @var PluginController $plugin_controller */
258b69d74f1SAndreas Gohr        global $plugin_controller;
259b69d74f1SAndreas Gohr        if (!$plugin_controller->disable($extension->getBase())) {
260b69d74f1SAndreas Gohr            throw new Exception('pluginlistsaveerror');
261b69d74f1SAndreas Gohr        }
262b69d74f1SAndreas Gohr        self::purgeCache();
263b69d74f1SAndreas Gohr    }
264b69d74f1SAndreas Gohr
265b69d74f1SAndreas Gohr
266b69d74f1SAndreas Gohr    /**
267cf2dcf1bSAndreas Gohr     * Download an archive to a protected path
268cf2dcf1bSAndreas Gohr     *
269cf2dcf1bSAndreas Gohr     * @param string $url The url to get the archive from
270cf2dcf1bSAndreas Gohr     * @return string The path where the archive was saved
271cf2dcf1bSAndreas Gohr     * @throws Exception
272cf2dcf1bSAndreas Gohr     */
273cf2dcf1bSAndreas Gohr    public function downloadArchive($url)
274cf2dcf1bSAndreas Gohr    {
275cf2dcf1bSAndreas Gohr        // check the url
276cf2dcf1bSAndreas Gohr        if (!preg_match('/https?:\/\//i', $url)) {
277cf2dcf1bSAndreas Gohr            throw new Exception('error_badurl');
278cf2dcf1bSAndreas Gohr        }
279cf2dcf1bSAndreas Gohr
280cf2dcf1bSAndreas Gohr        // try to get the file from the path (used as plugin name fallback)
281cf2dcf1bSAndreas Gohr        $file = parse_url($url, PHP_URL_PATH);
282cf2dcf1bSAndreas Gohr        $file = $file ? PhpString::basename($file) : md5($url);
283cf2dcf1bSAndreas Gohr
284cf2dcf1bSAndreas Gohr        // download
285cf2dcf1bSAndreas Gohr        $http = new DokuHTTPClient();
286cf2dcf1bSAndreas Gohr        $http->max_bodysize = 0;
287cf2dcf1bSAndreas Gohr        $http->keep_alive = false; // we do single ops here, no need for keep-alive
288cf2dcf1bSAndreas Gohr        $http->agent = 'DokuWiki HTTP Client (Extension Manager)';
289cf2dcf1bSAndreas Gohr
290f17690f7SAndreas Gohr        // large downloads may take a while on slow connections, so we try to extend the timeout to 4 minutes
291f17690f7SAndreas Gohr        // 4 minutes was chosen, because HTTP servers and proxies often have a 5 minute timeout
292811d6efaSsplitbrain        if (PHP_SAPI === 'cli' || @set_time_limit(60 * 4)) {
293f17690f7SAndreas Gohr            $http->timeout = 60 * 4 - 5; // nearly 4 minutes
294f17690f7SAndreas Gohr        } else {
295f17690f7SAndreas Gohr            $http->timeout = 25; // max. 25 sec (a bit less than default execution time)
296f17690f7SAndreas Gohr        }
297f17690f7SAndreas Gohr
298cf2dcf1bSAndreas Gohr        $data = $http->get($url);
299cf2dcf1bSAndreas Gohr        if ($data === false) throw new Exception('error_download', [$url, $http->error, $http->status]);
300cf2dcf1bSAndreas Gohr
301cf2dcf1bSAndreas Gohr        // get filename from headers
3027c184cfcSAndreas Gohr        if (
3037c184cfcSAndreas Gohr            preg_match(
304cf2dcf1bSAndreas Gohr                '/attachment;\s*filename\s*=\s*"([^"]*)"/i',
305cf2dcf1bSAndreas Gohr                (string)($http->resp_headers['content-disposition'] ?? ''),
306cf2dcf1bSAndreas Gohr                $match
3077c184cfcSAndreas Gohr            )
3087c184cfcSAndreas Gohr        ) {
309cf2dcf1bSAndreas Gohr            $file = PhpString::basename($match[1]);
310cf2dcf1bSAndreas Gohr        }
311cf2dcf1bSAndreas Gohr
312cf2dcf1bSAndreas Gohr        // clean up filename
313cf2dcf1bSAndreas Gohr        $file = $this->fileToBase($file);
314cf2dcf1bSAndreas Gohr
315cf2dcf1bSAndreas Gohr        // create tmp directory for download
316cf2dcf1bSAndreas Gohr        $tmp = $this->mkTmpDir();
317cf2dcf1bSAndreas Gohr
318cf2dcf1bSAndreas Gohr        // save the file
319cf2dcf1bSAndreas Gohr        if (@file_put_contents("$tmp/$file", $data) === false) {
320cf2dcf1bSAndreas Gohr            throw new Exception('error_save');
321cf2dcf1bSAndreas Gohr        }
322cf2dcf1bSAndreas Gohr
323cf2dcf1bSAndreas Gohr        return "$tmp/$file";
324cf2dcf1bSAndreas Gohr    }
325cf2dcf1bSAndreas Gohr
326cf2dcf1bSAndreas Gohr
327cf2dcf1bSAndreas Gohr    /**
328cf2dcf1bSAndreas Gohr     * Delete outdated files
329cf2dcf1bSAndreas Gohr     */
330cf2dcf1bSAndreas Gohr    public function removeDeletedFiles(Extension $extension)
331cf2dcf1bSAndreas Gohr    {
332cf2dcf1bSAndreas Gohr        $extensiondir = $extension->getInstallDir();
333cf2dcf1bSAndreas Gohr        $definitionfile = $extensiondir . '/deleted.files';
334cf2dcf1bSAndreas Gohr        if (!file_exists($definitionfile)) return;
335cf2dcf1bSAndreas Gohr
336cf2dcf1bSAndreas Gohr        $list = file($definitionfile);
337cf2dcf1bSAndreas Gohr        foreach ($list as $line) {
338cf2dcf1bSAndreas Gohr            $line = trim(preg_replace('/#.*$/', '', $line));
339cf2dcf1bSAndreas Gohr            $line = str_replace('..', '', $line); // do not run out of the extension directory
340cf2dcf1bSAndreas Gohr            if (!$line) continue;
341cf2dcf1bSAndreas Gohr
342cf2dcf1bSAndreas Gohr            $file = $extensiondir . '/' . $line;
343cf2dcf1bSAndreas Gohr            if (!file_exists($file)) continue;
344cf2dcf1bSAndreas Gohr
345cf2dcf1bSAndreas Gohr            io_rmdir($file, true);
346cf2dcf1bSAndreas Gohr        }
347cf2dcf1bSAndreas Gohr    }
348cf2dcf1bSAndreas Gohr
34925d28a01SAndreas Gohr    /**
35025d28a01SAndreas Gohr     * Purge all caches
35125d28a01SAndreas Gohr     */
352cf2dcf1bSAndreas Gohr    public static function purgeCache()
353cf2dcf1bSAndreas Gohr    {
354cf2dcf1bSAndreas Gohr        // expire dokuwiki caches
355cf2dcf1bSAndreas Gohr        // touching local.php expires wiki page, JS and CSS caches
356cf2dcf1bSAndreas Gohr        global $config_cascade;
357cf2dcf1bSAndreas Gohr        @touch(reset($config_cascade['main']['local']));
358cf2dcf1bSAndreas Gohr
359cf2dcf1bSAndreas Gohr        if (function_exists('opcache_reset')) {
360e206a495SAndreas Gohr            @opcache_reset();
361cf2dcf1bSAndreas Gohr        }
362cf2dcf1bSAndreas Gohr    }
363cf2dcf1bSAndreas Gohr
364cf2dcf1bSAndreas Gohr    /**
365160d3688SAndreas Gohr     * Get the list of processed extensions and their status during an installation run
36625d28a01SAndreas Gohr     *
36725d28a01SAndreas Gohr     * @return array id => status
36825d28a01SAndreas Gohr     */
36925d28a01SAndreas Gohr    public function getProcessed()
37025d28a01SAndreas Gohr    {
37125d28a01SAndreas Gohr        return $this->processed;
37225d28a01SAndreas Gohr    }
37325d28a01SAndreas Gohr
374b2a05b76SAndreas Gohr
375b2a05b76SAndreas Gohr    /**
376b2a05b76SAndreas Gohr     * Ensure that the given extension is compatible with the current PHP version
377b2a05b76SAndreas Gohr     *
378b2a05b76SAndreas Gohr     * Throws an exception if the extension is not compatible
379b2a05b76SAndreas Gohr     *
380b2a05b76SAndreas Gohr     * @param Extension $extension
381b2a05b76SAndreas Gohr     * @throws Exception
382b2a05b76SAndreas Gohr     */
3834fd6a1d7SAndreas Gohr    public static function ensurePhpCompatibility(Extension $extension)
384b2a05b76SAndreas Gohr    {
385b2a05b76SAndreas Gohr        $min = $extension->getMinimumPHPVersion();
386b2a05b76SAndreas Gohr        if ($min && version_compare(PHP_VERSION, $min, '<')) {
387b2a05b76SAndreas Gohr            throw new Exception('error_minphp', [$extension->getId(), $min, PHP_VERSION]);
388b2a05b76SAndreas Gohr        }
389b2a05b76SAndreas Gohr
390b2a05b76SAndreas Gohr        $max = $extension->getMaximumPHPVersion();
391b2a05b76SAndreas Gohr        if ($max && version_compare(PHP_VERSION, $max, '>')) {
392b2a05b76SAndreas Gohr            throw new Exception('error_maxphp', [$extension->getId(), $max, PHP_VERSION]);
393b2a05b76SAndreas Gohr        }
394b2a05b76SAndreas Gohr    }
395b2a05b76SAndreas Gohr
3964fd6a1d7SAndreas Gohr    /**
3974fd6a1d7SAndreas Gohr     * Ensure the file permissions are correct before attempting to install
3984fd6a1d7SAndreas Gohr     *
3994fd6a1d7SAndreas Gohr     * @throws Exception if the permissions are not correct
4004fd6a1d7SAndreas Gohr     */
4014fd6a1d7SAndreas Gohr    public static function ensurePermissions(Extension $extension)
4024fd6a1d7SAndreas Gohr    {
4034fd6a1d7SAndreas Gohr        $target = $extension->getInstallDir();
4044fd6a1d7SAndreas Gohr
405396ae2b1SAndreas Gohr        // bundled plugins do not need to be writable
406396ae2b1SAndreas Gohr        if ($extension->isBundled()) {
407396ae2b1SAndreas Gohr            return;
408396ae2b1SAndreas Gohr        }
409396ae2b1SAndreas Gohr
4104fd6a1d7SAndreas Gohr        // updates
4114fd6a1d7SAndreas Gohr        if (file_exists($target)) {
4124fd6a1d7SAndreas Gohr            if (!is_writable($target)) throw new Exception('noperms');
4134fd6a1d7SAndreas Gohr            return;
4144fd6a1d7SAndreas Gohr        }
4154fd6a1d7SAndreas Gohr
4164fd6a1d7SAndreas Gohr        // new installs
4174fd6a1d7SAndreas Gohr        $target = dirname($target);
4184fd6a1d7SAndreas Gohr        if (!is_writable($target)) {
4194fd6a1d7SAndreas Gohr            if ($extension->isTemplate()) throw new Exception('notplperms');
4204fd6a1d7SAndreas Gohr            throw new Exception('nopluginperms');
4214fd6a1d7SAndreas Gohr        }
4224fd6a1d7SAndreas Gohr    }
423b2a05b76SAndreas Gohr
42425d28a01SAndreas Gohr    /**
425cf2dcf1bSAndreas Gohr     * Get a base name from an archive name (we don't trust)
426cf2dcf1bSAndreas Gohr     *
427cf2dcf1bSAndreas Gohr     * @param string $file
428cf2dcf1bSAndreas Gohr     * @return string
429cf2dcf1bSAndreas Gohr     */
430cf2dcf1bSAndreas Gohr    protected function fileToBase($file)
431cf2dcf1bSAndreas Gohr    {
432cf2dcf1bSAndreas Gohr        $base = PhpString::basename($file);
433*a1ef4d62SAndreas Gohr        $base = preg_replace('/\.(tar\.gz|tar\.bz|tar\.bz2|tar|tgz|tbz|zip|archive)$/', '', $base);
434cf2dcf1bSAndreas Gohr        return preg_replace('/\W+/', '', $base);
435cf2dcf1bSAndreas Gohr    }
436cf2dcf1bSAndreas Gohr
437cf2dcf1bSAndreas Gohr    /**
438cf2dcf1bSAndreas Gohr     * Returns a temporary directory
439cf2dcf1bSAndreas Gohr     *
440cf2dcf1bSAndreas Gohr     * The directory is registered for cleanup when the class is destroyed
441cf2dcf1bSAndreas Gohr     *
442cf2dcf1bSAndreas Gohr     * @return string
443cf2dcf1bSAndreas Gohr     * @throws Exception
444cf2dcf1bSAndreas Gohr     */
445cf2dcf1bSAndreas Gohr    protected function mkTmpDir()
446cf2dcf1bSAndreas Gohr    {
447cf2dcf1bSAndreas Gohr        try {
448cf2dcf1bSAndreas Gohr            $dir = io_mktmpdir();
449cf2dcf1bSAndreas Gohr        } catch (\Exception $e) {
450cf2dcf1bSAndreas Gohr            throw new Exception('error_dircreate', [], $e);
451cf2dcf1bSAndreas Gohr        }
452cf2dcf1bSAndreas Gohr        if (!$dir) throw new Exception('error_dircreate');
453cf2dcf1bSAndreas Gohr        $this->temporary[] = $dir;
454cf2dcf1bSAndreas Gohr        return $dir;
455cf2dcf1bSAndreas Gohr    }
456cf2dcf1bSAndreas Gohr
457cf2dcf1bSAndreas Gohr    /**
458cf2dcf1bSAndreas Gohr     * Find all extensions in a given directory
459cf2dcf1bSAndreas Gohr     *
460cf2dcf1bSAndreas Gohr     * This allows us to install extensions from archives that contain multiple extensions and
461cf2dcf1bSAndreas Gohr     * also caters for the fact that archives may or may not contain subdirectories for the extension(s).
462cf2dcf1bSAndreas Gohr     *
463cf2dcf1bSAndreas Gohr     * @param string $dir
464cf2dcf1bSAndreas Gohr     * @return Extension[]
465cf2dcf1bSAndreas Gohr     */
466cf2dcf1bSAndreas Gohr    protected function findExtensions($dir, $base = null)
467cf2dcf1bSAndreas Gohr    {
468cf2dcf1bSAndreas Gohr        // first check for plugin.info.txt or template.info.txt
469cf2dcf1bSAndreas Gohr        $extensions = [];
470cf2dcf1bSAndreas Gohr        $iterator = new RecursiveDirectoryIterator($dir);
471cf2dcf1bSAndreas Gohr        foreach (new RecursiveIteratorIterator($iterator) as $file) {
472cf2dcf1bSAndreas Gohr            if (
473cf2dcf1bSAndreas Gohr                $file->getFilename() === 'plugin.info.txt' ||
474cf2dcf1bSAndreas Gohr                $file->getFilename() === 'template.info.txt'
475cf2dcf1bSAndreas Gohr            ) {
47625d28a01SAndreas Gohr                $extensions[] = Extension::createFromDirectory($file->getPath());
477cf2dcf1bSAndreas Gohr            }
478cf2dcf1bSAndreas Gohr        }
479cf2dcf1bSAndreas Gohr        if ($extensions) return $extensions;
480cf2dcf1bSAndreas Gohr
481cf2dcf1bSAndreas Gohr        // still nothing? we assume this to be a single extension that is either
482cf2dcf1bSAndreas Gohr        // directly in the given directory or in single subdirectory
483cf2dcf1bSAndreas Gohr        $files = glob($dir . '/*');
484cf2dcf1bSAndreas Gohr        if (count($files) === 1 && is_dir($files[0])) {
485cf2dcf1bSAndreas Gohr            $dir = $files[0];
486cf2dcf1bSAndreas Gohr        }
487cf2dcf1bSAndreas Gohr        return [Extension::createFromDirectory($dir, null, $base)];
488cf2dcf1bSAndreas Gohr    }
489cf2dcf1bSAndreas Gohr
490cf2dcf1bSAndreas Gohr    /**
491cf2dcf1bSAndreas Gohr     * Extract the given archive to the given target directory
492cf2dcf1bSAndreas Gohr     *
493cf2dcf1bSAndreas Gohr     * Auto-guesses the archive type
494cf2dcf1bSAndreas Gohr     * @throws Exception
495cf2dcf1bSAndreas Gohr     */
496cf2dcf1bSAndreas Gohr    protected function extractArchive($archive, $target)
497cf2dcf1bSAndreas Gohr    {
498cf2dcf1bSAndreas Gohr        $fh = fopen($archive, 'rb');
499cf2dcf1bSAndreas Gohr        if (!$fh) throw new Exception('error_archive_read', [$archive]);
500cf2dcf1bSAndreas Gohr        $magic = fread($fh, 5);
501cf2dcf1bSAndreas Gohr        fclose($fh);
502cf2dcf1bSAndreas Gohr
503cf2dcf1bSAndreas Gohr        if (strpos($magic, "\x50\x4b\x03\x04") === 0) {
504cf2dcf1bSAndreas Gohr            $archiver = new Zip();
505cf2dcf1bSAndreas Gohr        } else {
506cf2dcf1bSAndreas Gohr            $archiver = new Tar();
507cf2dcf1bSAndreas Gohr        }
508cf2dcf1bSAndreas Gohr        try {
509cf2dcf1bSAndreas Gohr            $archiver->open($archive);
510cf2dcf1bSAndreas Gohr            $archiver->extract($target);
511cf2dcf1bSAndreas Gohr        } catch (ArchiveIOException | ArchiveCorruptedException | ArchiveIllegalCompressionException $e) {
512cf2dcf1bSAndreas Gohr            throw new Exception('error_archive_extract', [$archive, $e->getMessage()], $e);
513cf2dcf1bSAndreas Gohr        }
514cf2dcf1bSAndreas Gohr    }
515cf2dcf1bSAndreas Gohr
516cf2dcf1bSAndreas Gohr    /**
517cf2dcf1bSAndreas Gohr     * Copy with recursive sub-directory support
518cf2dcf1bSAndreas Gohr     *
519cf2dcf1bSAndreas Gohr     * @param string $src filename path to file
520cf2dcf1bSAndreas Gohr     * @param string $dst filename path to file
521cf2dcf1bSAndreas Gohr     * @throws Exception
522cf2dcf1bSAndreas Gohr     */
523cf2dcf1bSAndreas Gohr    protected function dircopy($src, $dst)
524cf2dcf1bSAndreas Gohr    {
525cf2dcf1bSAndreas Gohr        global $conf;
526cf2dcf1bSAndreas Gohr
527cf2dcf1bSAndreas Gohr        if (is_dir($src)) {
528cf2dcf1bSAndreas Gohr            if (!$dh = @opendir($src)) {
529cf2dcf1bSAndreas Gohr                throw new Exception('error_copy_read', [$src]);
530cf2dcf1bSAndreas Gohr            }
531cf2dcf1bSAndreas Gohr
532cf2dcf1bSAndreas Gohr            if (io_mkdir_p($dst)) {
533cf2dcf1bSAndreas Gohr                while (false !== ($f = readdir($dh))) {
534cf2dcf1bSAndreas Gohr                    if ($f == '..' || $f == '.') continue;
535cf2dcf1bSAndreas Gohr                    $this->dircopy("$src/$f", "$dst/$f");
536cf2dcf1bSAndreas Gohr                }
537cf2dcf1bSAndreas Gohr            } else {
538cf2dcf1bSAndreas Gohr                throw new Exception('error_copy_mkdir', [$dst]);
539cf2dcf1bSAndreas Gohr            }
540cf2dcf1bSAndreas Gohr
541cf2dcf1bSAndreas Gohr            closedir($dh);
542cf2dcf1bSAndreas Gohr        } else {
543cf2dcf1bSAndreas Gohr            $existed = file_exists($dst);
544cf2dcf1bSAndreas Gohr
545cf2dcf1bSAndreas Gohr            if (!@copy($src, $dst)) {
546cf2dcf1bSAndreas Gohr                throw new Exception('error_copy_copy', [$src, $dst]);
547cf2dcf1bSAndreas Gohr            }
548cf2dcf1bSAndreas Gohr            if (!$existed && $conf['fperm']) chmod($dst, $conf['fperm']);
549cf2dcf1bSAndreas Gohr            @touch($dst, filemtime($src));
550cf2dcf1bSAndreas Gohr        }
551cf2dcf1bSAndreas Gohr    }
552cf2dcf1bSAndreas Gohr
553cf2dcf1bSAndreas Gohr    /**
55425d28a01SAndreas Gohr     * Reset caches if needed
555cf2dcf1bSAndreas Gohr     */
556cf2dcf1bSAndreas Gohr    protected function cleanUp()
557cf2dcf1bSAndreas Gohr    {
558cf2dcf1bSAndreas Gohr        if ($this->isDirty) {
559cf2dcf1bSAndreas Gohr            self::purgeCache();
560cf2dcf1bSAndreas Gohr            $this->isDirty = false;
561cf2dcf1bSAndreas Gohr        }
562cf2dcf1bSAndreas Gohr    }
563cf2dcf1bSAndreas Gohr}
564