1<?php
2
3namespace Roundcube\Composer;
4
5use Composer\Installer\LibraryInstaller;
6use Composer\Package\Version\VersionParser;
7use Composer\Package\PackageInterface;
8use Composer\Repository\InstalledRepositoryInterface;
9use Composer\Util\ProcessExecutor;
10use Composer\Util\Filesystem;
11use React\Promise\PromiseInterface;
12
13/**
14 * @category Plugins
15 * @package  PluginInstaller
16 * @author   Till Klampaeckel <till@php.net>
17 * @author   Thomas Bruederli <thomas@roundcube.net>
18 * @author   Philip Weir <roundcube@tehinterweb.co.uk>
19 * @license  GPL-3.0+
20 * @version  GIT: <git_id>
21 * @link     http://github.com/roundcube/plugin-installer
22 */
23class ExtensionInstaller extends LibraryInstaller
24{
25    protected $composer_type;
26
27    /**
28     * {@inheritDoc}
29     */
30    public function getInstallPath(PackageInterface $package)
31    {
32        static $vendorDir;
33        if ($vendorDir === null) {
34            $vendorDir = $this->getVendorDir();
35        }
36
37        return sprintf('%s/%s', $vendorDir, $this->getPackageName($package));
38    }
39
40    /**
41     * {@inheritDoc}
42     */
43    public function install(InstalledRepositoryInterface $repo, PackageInterface $package)
44    {
45        // initialize Roundcube environment
46        define('INSTALL_PATH', getcwd() . '/');
47        include_once(INSTALL_PATH . 'program/include/clisetup.php');
48
49        $this->rcubeVersionCheck($package);
50
51        $self = $this;
52        $postInstall = function() use ($self, $package) {
53            $config_file  = $self->rcubeConfigFile();
54            $package_name = $self->getPackageName($package);
55            $package_dir  = $self->getVendorDir() . DIRECTORY_SEPARATOR . $package_name;
56            $extra        = $package->getExtra();
57
58            if (is_writeable($config_file) && php_sapi_name() == 'cli' && $this->confirmInstall($package_name)) {
59                $self->rcubeAlterConfig($package_name);
60            }
61
62            // copy config.inc.php.dist -> config.inc.php
63            if (is_file($package_dir . DIRECTORY_SEPARATOR . 'config.inc.php.dist')) {
64                $config_exists   = false;
65                $alt_config_file = $self->rcubeConfigFile($package_name . '.inc.php');
66
67                if (is_file($package_dir . DIRECTORY_SEPARATOR . 'config.inc.php')) {
68                    $config_exists = true;
69                }
70                elseif (is_file($alt_config_file)) {
71                    $config_exists = true;
72                }
73
74                if (!$config_exists && is_writeable($package_dir)) {
75                    $self->io->write("<info>Creating package config file</info>");
76                    copy($package_dir . DIRECTORY_SEPARATOR . 'config.inc.php.dist', $package_dir . DIRECTORY_SEPARATOR . 'config.inc.php');
77                }
78            }
79
80            // initialize database schema
81            if (!empty($extra['roundcube']['sql-dir'])) {
82                if ($sqldir = realpath($package_dir . DIRECTORY_SEPARATOR . $extra['roundcube']['sql-dir'])) {
83                    $self->io->write("<info>Running database initialization script for $package_name</info>");
84
85                    $roundcube_version = self::versionNormalize(RCMAIL_VERSION);
86                    if (self::versionCompare($roundcube_version, '1.2.0', '>=')) {
87                        \rcmail_utils::db_init($sqldir);
88                    }
89                    else {
90                        throw new \Exception("Database initialization failed. Roundcube 1.2.0 or above required.");
91                    }
92                }
93            }
94
95            // run post-install script
96            if (!empty($extra['roundcube']['post-install-script'])) {
97                $self->rcubeRunScript($extra['roundcube']['post-install-script'], $package);
98            }
99        };
100
101        $promise = parent::install($repo, $package);
102
103        // Composer v2 might return a promise here
104        if ($promise instanceof PromiseInterface) {
105            return $promise->then($postInstall);
106        }
107
108        // If not, execute the code right away (composer v1, or v2 without async)
109        $postInstall();
110    }
111
112    /**
113     * {@inheritDoc}
114     */
115    public function update(InstalledRepositoryInterface $repo, PackageInterface $initial, PackageInterface $target)
116    {
117        // initialize Roundcube environment
118        define('INSTALL_PATH', getcwd() . '/');
119        include_once(INSTALL_PATH . 'program/include/clisetup.php');
120
121        $this->rcubeVersionCheck($target);
122
123        $self  = $this;
124        $extra = $target->getExtra();
125        $fs    = new Filesystem();
126
127        // backup persistent files e.g. config.inc.php
128        $package_name     = $self->getPackageName($initial);
129        $package_dir      = $self->getVendorDir() . DIRECTORY_SEPARATOR . $package_name;
130        $temp_dir         = $package_dir . '-' . sprintf('%010d%010d', mt_rand(), mt_rand());
131
132        // make a backup of existing files (for restoring persistent files)
133        $fs->copy($package_dir, $temp_dir);
134
135        $postUpdate = function() use ($self, $target, $extra, $fs, $temp_dir) {
136            $package_name = $self->getPackageName($target);
137            $package_dir  = $self->getVendorDir() . DIRECTORY_SEPARATOR . $package_name;
138
139            // restore persistent files
140            $persistent_files = !empty($extra['roundcube']['persistent-files']) ? $extra['roundcube']['persistent-files'] : ['config.inc.php'];
141            foreach ($persistent_files as $file) {
142                $path = $temp_dir . DIRECTORY_SEPARATOR . $file;
143                if (is_readable($path)) {
144                    if ($fs->copy($path, $package_dir . DIRECTORY_SEPARATOR . $file)) {
145                        $self->io->write("<info>Restored $package_name/$file</info>");
146                    }
147                    else {
148                        throw new \Exception("Restoring " . $file . " failed.");
149                    }
150                }
151            }
152            // remove backup folder
153            $fs->remove($temp_dir);
154
155            // update database schema
156            if (!empty($extra['roundcube']['sql-dir'])) {
157                if ($sqldir = realpath($package_dir . DIRECTORY_SEPARATOR . $extra['roundcube']['sql-dir'])) {
158                    $self->io->write("<info>Updating database schema for $package_name</info>");
159
160                    $roundcube_version = self::versionNormalize(RCMAIL_VERSION);
161                    if (self::versionCompare($roundcube_version, '1.2.0', '>=')) {
162                        \rcmail_utils::db_update($sqldir, $package_name);
163                    }
164                    else {
165                        throw new \Exception("Database update failed. Roundcube 1.2.0 or above required.");
166                    }
167                }
168            }
169
170            // run post-update script
171            if (!empty($extra['roundcube']['post-update-script'])) {
172                $self->rcubeRunScript($extra['roundcube']['post-update-script'], $target);
173            }
174        };
175
176        $promise = parent::update($repo, $initial, $target);
177
178        // Composer v2 might return a promise here
179        if ($promise instanceof PromiseInterface) {
180            return $promise->then($postUpdate);
181        }
182
183        // If not, execute the code right away (composer v1, or v2 without async)
184        $postUpdate();
185    }
186
187    /**
188     * {@inheritDoc}
189     */
190    public function uninstall(InstalledRepositoryInterface $repo, PackageInterface $package)
191    {
192        // initialize Roundcube environment
193        define('INSTALL_PATH', getcwd() . '/');
194        include_once(INSTALL_PATH . 'program/include/clisetup.php');
195
196        $self   = $this;
197        $config = $self->composer->getConfig()->get('roundcube');
198
199        $postUninstall = function() use ($self, $package, $config) {
200            // post-uninstall: deactivate package
201            $package_name = $self->getPackageName($package);
202            $package_dir  = $self->getVendorDir() . DIRECTORY_SEPARATOR . $package_name;
203
204            $self->rcubeAlterConfig($package_name, false);
205
206            // run post-uninstall script
207            $extra = $package->getExtra();
208            if (!empty($extra['roundcube']['post-uninstall-script'])) {
209                $self->rcubeRunScript($extra['roundcube']['post-uninstall-script'], $package);
210            }
211
212            // remove package folder
213            if (!empty($config['uninstall-remove-folder'])) {
214                $fs = new Filesystem();
215                $fs->remove($package_dir);
216                $self->io->write("<info>Removed $package_name files</info>");
217            }
218        };
219
220        $promise = parent::uninstall($repo, $package);
221
222        // Composer v2 might return a promise here
223        if ($promise instanceof PromiseInterface) {
224            return $promise->then($postUninstall);
225        }
226
227        // If not, execute the code right away (composer v1, or v2 without async)
228        $postUninstall();
229    }
230
231    /**
232     * {@inheritDoc}
233     */
234    public function supports($packageType)
235    {
236        return $packageType === $this->composer_type;
237    }
238
239    /**
240     * Setup vendor directory to one of these two:
241     *
242     * @return string
243     */
244    public function getVendorDir()
245    {
246        return getcwd();
247    }
248
249    /**
250     * Extract the (valid) package name from the package object
251     */
252    protected function getPackageName(PackageInterface $package)
253    {
254        @list($vendor, $packageName) = explode('/', $package->getPrettyName());
255
256        return strtr($packageName, '-', '_');
257    }
258
259    /**
260     * Check version requirements from the "extra" block of a package
261     * against the local Roundcube version
262     */
263    private function rcubeVersionCheck($package)
264    {
265        // read rcube version from iniset
266        $rcubeVersion = self::versionNormalize(RCMAIL_VERSION);
267
268        if (empty($rcubeVersion)) {
269            throw new \Exception("Unable to find a Roundcube installation in $rootdir");
270        }
271
272        $extra = $package->getExtra();
273
274        if (!empty($extra['roundcube'])) {
275            foreach (['min-version' => '>=', 'max-version' => '<='] as $key => $operator) {
276                if (!empty($extra['roundcube'][$key])) {
277                    $version = self::versionNormalize($extra['roundcube'][$key]);
278                    if (!self::versionCompare($rcubeVersion, $version, $operator)) {
279                        throw new \Exception("Version check failed! " . $package->getName() . " requires Roundcube version $operator $version, $rcubeVersion was detected.");
280                    }
281                }
282            }
283        }
284    }
285
286    /**
287     * Add or remove the given package to the Roundcube config.
288     */
289    private function rcubeAlterConfig($package_name, $add = true)
290    {
291        $config_file = $this->rcubeConfigFile();
292        @include($config_file);
293        $success = false;
294        $varname = '$config';
295
296        if (empty($config) && !empty($rcmail_config)) {
297            $config  = $rcmail_config;
298            $varname = '$rcmail_config';
299        }
300
301        if (!empty($config) && is_writeable($config_file)) {
302            $config_template = @file_get_contents($config_file) ?: '';
303
304            if ($config = $this->getConfig($package_name, $config, $add)) {
305                list($config_name, $config_val) = $config;
306                $count = 0;
307
308                if (empty($config_val)) {
309                    $new_config = preg_replace(
310                        "/(\\$varname\['$config_name'\])\s+=\s+(.+);/Uims",
311                        "",
312                        $config_template, -1, $count);
313                }
314                else {
315                    $new_config = preg_replace(
316                        "/(\\$varname\['$config_name'\])\s+=\s+(.+);/Uims",
317                        "\\1 = " . $config_val,
318                        $config_template, -1, $count);
319                }
320
321                // config option does not exist yet, add it...
322                if (!$count) {
323                    $var_txt    = "\n{$varname}['$config_name'] = $config_val\n";
324                    $new_config = str_replace('?>', $var_txt . '?>', $config_template, $count);
325
326                    if (!$count) {
327                        $new_config = $config_template . $var_txt;
328                    }
329                }
330
331                $success = file_put_contents($config_file, $new_config);
332            }
333        }
334
335        if ($success && php_sapi_name() == 'cli') {
336            $this->io->write("<info>Updated local config at $config_file</info>");
337        }
338
339        return $success;
340    }
341
342    /**
343    * Ask the user to confirm installation
344    */
345    protected function confirmInstall($package_name)
346    {
347        return false;
348    }
349
350    /**
351    * Generate Roundcube config entry
352    */
353    protected function getConfig($package_name, $cur_config, $add)
354    {
355        return false;
356    }
357
358    /**
359     * Helper method to get an absolute path to the local Roundcube config file
360     */
361    private function rcubeConfigFile($file = 'config.inc.php')
362    {
363        $config = new \rcube_config();
364        $paths  = $config->resolve_paths($file);
365        $path   = getcwd() . '/config/' . $file;
366
367        foreach ($paths as $fpath) {
368            if ($fpath && is_file($fpath) && is_readable($fpath)) {
369                $path = $fpath;
370                break;
371            }
372        }
373
374        return realpath($path);
375    }
376
377    /**
378     * Run the given script file
379     */
380    private function rcubeRunScript($script, PackageInterface $package)
381    {
382        $package_name = $this->getPackageName($package);
383        $package_type = $package->getType();
384        $package_dir  = $this->getVendorDir() . DIRECTORY_SEPARATOR . $package_name;
385
386        // check for executable shell script
387        if (($scriptfile = realpath($package_dir . DIRECTORY_SEPARATOR . $script)) && is_executable($scriptfile)) {
388            $script = $scriptfile;
389        }
390
391        // run PHP script in Roundcube context
392        if ($scriptfile && preg_match('/\.php$/', $scriptfile)) {
393            $incdir = realpath(getcwd() . '/program/include');
394            include_once($incdir . '/iniset.php');
395            include($scriptfile);
396        }
397        // attempt to execute the given string as shell commands
398        else {
399            $process = new ProcessExecutor($this->io);
400            $exitCode = $process->execute($script, $output, $package_dir);
401            if ($exitCode !== 0) {
402                throw new \RuntimeException('Error executing script: '. $process->getErrorOutput(), $exitCode);
403            }
404        }
405    }
406
407    /**
408     * normalize Roundcube version string
409     */
410    private static function versionNormalize($version)
411    {
412        $parser = new VersionParser;
413
414        return $parser->normalize(str_replace('-git', '.999', $version));
415    }
416
417    /**
418     * version_compare() wrapper, originally from composer/semver
419     */
420    private static function versionCompare($a, $b, $operator, $compareBranches = false)
421    {
422        $aIsBranch = 'dev-' === substr($a, 0, 4);
423        $bIsBranch = 'dev-' === substr($b, 0, 4);
424
425        if ($aIsBranch && $bIsBranch) {
426            return $operator === '==' && $a === $b;
427        }
428
429        // when branches are not comparable, we make sure dev branches never match anything
430        if (!$compareBranches && ($aIsBranch || $bIsBranch)) {
431            return false;
432        }
433
434        return version_compare($a, $b, $operator);
435    }
436}
437