Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
3.49% covered (danger)
3.49%
15 / 430
2.94% covered (danger)
2.94%
1 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
InstallController
3.49% covered (danger)
3.49%
15 / 430
2.94% covered (danger)
2.94%
1 / 34
15088.54
0.00% covered (danger)
0.00%
0 / 1
 validateAutoConfigureConfig
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 attachDefaultListeners
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 disabledAction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 installBasicConfig
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 checkBasicConfig
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getSolrUrlFromImportConfig
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 fixbasicconfigAction
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 checkCache
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 fixcacheAction
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 checkDatabase
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 phpVersionIsNewEnough
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 checkDependencies
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 fixdependenciesAction
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
56
 fixdatabaseAction
0.00% covered (danger)
0.00%
0 / 82
0.00% covered (danger)
0.00%
0 / 1
420
 getPreCommands
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 getPostCommands
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 showsqlAction
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 checkILS
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 fixilsAction
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
182
 testSearchService
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 checkSolr
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 fixsolrAction
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 checkSecurity
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 fixSecurityConfiguration
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 fixsecurityAction
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 performsecurityfixAction
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
90
 checkSslCerts
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 fixsslcertsAction
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 testSslCertConfig
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 doneAction
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 homeAction
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getMinimalPhpVersion
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 getMinimalPhpVersionId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getComposerJson
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
1<?php
2
3/**
4 * Install Controller
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010, 2022.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Controller
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org Main Site
28 */
29
30namespace VuFind\Controller;
31
32use Laminas\Crypt\Password\Bcrypt;
33use Laminas\Mvc\MvcEvent;
34use VuFind\Config\Writer as ConfigWriter;
35use VuFind\Db\Service\TagServiceInterface;
36use VuFind\Db\Service\UserCardServiceInterface;
37use VuFind\Db\Service\UserServiceInterface;
38use VuFindSearch\Command\RetrieveCommand;
39
40use function count;
41use function defined;
42use function dirname;
43use function function_exists;
44use function in_array;
45use function is_callable;
46use function strlen;
47
48/**
49 * Class controls VuFind auto-configuration.
50 *
51 * @category VuFind
52 * @package  Controller
53 * @author   Demian Katz <demian.katz@villanova.edu>
54 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
55 * @link     https://vufind.org Main Site
56 */
57class InstallController extends AbstractBase
58{
59    use Feature\ConfigPathTrait;
60    use Feature\SecureDatabaseTrait;
61
62    /**
63     * Use preDispatch event to block access when appropriate.
64     *
65     * @param MvcEvent $e Event object
66     *
67     * @return void
68     */
69    public function validateAutoConfigureConfig(MvcEvent $e)
70    {
71        // If auto-configuration is disabled, prevent any other action from being
72        // accessed:
73        $config = $this->getConfig();
74        if (
75            !isset($config->System->autoConfigure)
76            || !$config->System->autoConfigure
77        ) {
78            $routeMatch = $e->getRouteMatch();
79            $routeMatch->setParam('action', 'disabled');
80        }
81    }
82
83    /**
84     * Register the default events for this controller
85     *
86     * @return void
87     */
88    protected function attachDefaultListeners()
89    {
90        parent::attachDefaultListeners();
91        $events = $this->getEventManager();
92        $events->attach(
93            MvcEvent::EVENT_DISPATCH,
94            [$this, 'validateAutoConfigureConfig'],
95            1000
96        );
97    }
98
99    /**
100     * Display disabled message.
101     *
102     * @return mixed
103     */
104    public function disabledAction()
105    {
106        return $this->createViewModel();
107    }
108
109    /**
110     * Copy the basic configuration file into position and report success or
111     * failure.
112     *
113     * @return bool
114     */
115    protected function installBasicConfig()
116    {
117        $config = $this->getForcedLocalConfigPath('config.ini');
118        if (!file_exists($config)) {
119            return copy($this->getBaseConfigFilePath('config.ini'), $config);
120        }
121        return true;        // report success if file already exists
122    }
123
124    /**
125     * Check if basic configuration is taken care of.
126     *
127     * @return array
128     */
129    protected function checkBasicConfig()
130    {
131        // Initialize status based on existence of config file...
132        $status = $this->installBasicConfig();
133
134        // See if the URL setting remains at the default (unless we already
135        // know we've failed):
136        if ($status) {
137            $config = $this->getConfig();
138            if (stristr($config->Site->url, 'myuniversity.edu')) {
139                $status = false;
140            }
141        }
142
143        return [
144            'title' => 'Basic Configuration', 'status' => $status,
145            'fix' => 'fixbasicconfig',
146        ];
147    }
148
149    /**
150     * Extract the Solr base URL from the SolrMarc configuration file,
151     * so a custom Solr port configured in install.php can be applied to
152     * the initial config.ini file.
153     *
154     * Return null if no custom Solr URL can be found.
155     *
156     * @return ?string
157     */
158    protected function getSolrUrlFromImportConfig()
159    {
160        $resolver = $this->serviceLocator->get(\VuFind\Config\PathResolver::class);
161        $importConfig = $resolver->getLocalConfigPath('import.properties', 'import');
162        if (file_exists($importConfig)) {
163            $props = file_get_contents($importConfig);
164            preg_match('|solr.hosturl\s*=\s*(https?://\w+:\d+/\w+)|', $props, $matches);
165            if (!empty($matches[1])) {
166                return $matches[1];
167            }
168        }
169        return null;
170    }
171
172    /**
173     * Display repair instructions for basic configuration problems.
174     *
175     * @return mixed
176     */
177    public function fixbasicconfigAction()
178    {
179        $view = $this->createViewModel();
180        $config = $this->getForcedLocalConfigPath('config.ini');
181        try {
182            if (!$this->installBasicConfig()) {
183                throw new \Exception('Cannot copy file into position.');
184            }
185            $writer = new ConfigWriter($config);
186            $serverUrl = $this->getViewRenderer()->plugin('serverurl');
187            $path = $this->url()->fromRoute('home');
188            $writer->set('Site', 'url', rtrim($serverUrl($path), '/'));
189            if ($solrUrl = $this->getSolrUrlFromImportConfig()) {
190                $writer->set('Index', 'url', $solrUrl);
191            }
192            if (!$writer->save()) {
193                throw new \Exception('Cannot write config to disk.');
194            }
195        } catch (\Exception $e) {
196            $view->configDir = dirname($config);
197            if (
198                function_exists('posix_getpwuid')
199                && function_exists('posix_geteuid')
200            ) {
201                $processUser = posix_getpwuid(posix_geteuid());
202                $view->runningUser = $processUser['name'];
203            }
204        }
205        return $view;
206    }
207
208    /**
209     * Check if the cache directory is writable.
210     *
211     * @return array
212     */
213    protected function checkCache()
214    {
215        $cache = $this->serviceLocator->get(\VuFind\Cache\Manager::class);
216        return [
217            'title' => 'Cache',
218            'status' => !$cache->hasDirectoryCreationError(),
219            'fix' => 'fixcache',
220        ];
221    }
222
223    /**
224     * Display repair instructions for cache problems.
225     *
226     * @return mixed
227     */
228    public function fixcacheAction()
229    {
230        $cache = $this->serviceLocator->get(\VuFind\Cache\Manager::class);
231        $view = $this->createViewModel();
232        $view->cacheDir = $cache->getCacheDir();
233        if (function_exists('posix_getpwuid') && function_exists('posix_geteuid')) {
234            $processUser = posix_getpwuid(posix_geteuid());
235            $view->runningUser = $processUser['name'];
236        }
237        return $view;
238    }
239
240    /**
241     * Check if the database is accessible.
242     *
243     * @return array
244     */
245    protected function checkDatabase()
246    {
247        try {
248            // Try to read the tags table just to see if we can connect to the DB:
249            $this->getDbService(TagServiceInterface::class)->getTagsByText('test');
250            $status = true;
251        } catch (\Exception $e) {
252            $status = false;
253        }
254        return [
255            'title' => 'Database', 'status' => $status, 'fix' => 'fixdatabase',
256        ];
257    }
258
259    /**
260     * Support method for check/fix dependencies code -- do we have a new enough
261     * version of PHP?
262     *
263     * @return bool
264     */
265    protected function phpVersionIsNewEnough()
266    {
267        // PHP_VERSION_ID was introduced in 5.2.7; if it's missing, we have a
268        // problem.
269        if (!defined('PHP_VERSION_ID')) {
270            return false;
271        }
272
273        // We need at least PHP version as defined in composer.json file:
274        return PHP_VERSION_ID >= $this->getMinimalPhpVersionId();
275    }
276
277    /**
278     * Check for missing dependencies.
279     *
280     * @return array
281     */
282    protected function checkDependencies()
283    {
284        $requiredFunctionsExist
285            = function_exists('mb_substr') && is_callable('imagecreatefromstring')
286              && function_exists('openssl_encrypt')
287              && class_exists('XSLTProcessor')
288              && defined('SODIUM_LIBRARY_VERSION');
289
290        return [
291            'title' => 'Dependencies',
292            'status' => $requiredFunctionsExist && $this->phpVersionIsNewEnough(),
293            'fix' => 'fixdependencies',
294        ];
295    }
296
297    /**
298     * Show how to fix dependency problems.
299     *
300     * @return mixed
301     */
302    public function fixdependenciesAction()
303    {
304        $problems = 0;
305
306        // Is our version new enough?
307        if (!$this->phpVersionIsNewEnough()) {
308            $msg = 'VuFind requires PHP version ' . $this->getMinimalPhpVersion()
309                . ' or newer; you are running ' . phpversion()
310                . '. Please upgrade.';
311            $this->flashMessenger()->addMessage($msg, 'error');
312            $problems++;
313        }
314
315        // Is the mbstring library missing?
316        if (!function_exists('mb_substr')) {
317            $msg
318                = 'Your PHP installation appears to be missing the mbstring plug-in.'
319                . ' For better language support, it is recommended that you add'
320                . ' this. For details on how to do this, see '
321                . 'https://vufind.org/wiki/installation '
322                . 'and look at the PHP installation instructions for your platform.';
323            $this->flashMessenger()->addMessage($msg, 'error');
324            $problems++;
325        }
326
327        // Is the GD library missing?
328        if (!is_callable('imagecreatefromstring')) {
329            $msg
330                = 'Your PHP installation appears to be missing the GD plug-in. '
331                . 'For better graphics support, it is recommended that you add this.'
332                . ' For details on how to do this, see '
333                . 'https://vufind.org/wiki/installation '
334                . 'and look at the PHP installation instructions for your platform.';
335            $this->flashMessenger()->addMessage($msg, 'error');
336            $problems++;
337        }
338
339        // Is the openssl library missing?
340        if (!function_exists('openssl_encrypt')) {
341            $msg
342                = 'Your PHP installation appears to be missing the openssl plug-in.'
343                . ' For better security support, it is recommended that you add'
344                . ' this. For details on how to do this, see '
345                . 'https://vufind.org/wiki/installation '
346                . 'and look at the PHP installation instructions for your platform.';
347            $this->flashMessenger()->addMessage($msg, 'error');
348            $problems++;
349        }
350
351        // Is the XSL library missing?
352        if (!class_exists('XSLTProcessor')) {
353            $msg
354                = 'Your PHP installation appears to be missing the XSL plug-in.'
355                . ' For details on how to do this, see '
356                . 'https://vufind.org/wiki/installation '
357                . 'and look at the PHP installation instructions for your platform.';
358            $this->flashMessenger()->addMessage($msg, 'error');
359            $problems++;
360        }
361
362        // Is the sodium extension missing?
363        if (!defined('SODIUM_LIBRARY_VERSION')) {
364            $msg
365                = 'Your PHP installation appears to be missing the sodium plug-in.'
366                . ' For details on how to do this, see '
367                . 'https://vufind.org/wiki/installation '
368                . 'and look at the PHP installation instructions for your platform.';
369            $this->flashMessenger()->addMessage($msg, 'error');
370            $problems++;
371        }
372
373        return $this->createViewModel(['problems' => $problems]);
374    }
375
376    /**
377     * Display repair instructions for database problems.
378     *
379     * @return mixed
380     */
381    public function fixdatabaseAction()
382    {
383        $view = $this->createViewModel();
384        $view->dbname = $this->params()->fromPost('dbname', 'vufind');
385        $view->dbuser = $this->params()->fromPost('dbuser', 'vufind');
386        $view->dbhost = $this->params()->fromPost('dbhost', 'localhost');
387        $view->vufindhost = $this->params()->fromPost('vufindhost', 'localhost');
388        $view->dbrootuser = $this->params()->fromPost('dbrootuser', 'root');
389        $view->driver = $this->params()->fromPost('driver', 'mysql');
390
391        $skip = $this->params()->fromPost('printsql', 'nope') == 'Skip';
392
393        if (!preg_match('/^\w*$/', $view->dbname)) {
394            $this->flashMessenger()
395                ->addMessage('Database name must be alphanumeric.', 'error');
396        } elseif (!preg_match('/^\w*$/', $view->dbuser)) {
397            $this->flashMessenger()
398                ->addMessage('Database user must be alphanumeric.', 'error');
399        } elseif ($skip || $this->formWasSubmitted()) {
400            $newpass = $this->params()->fromPost('dbpass');
401            $newpassConf = $this->params()->fromPost('dbpassconfirm');
402            if ((empty($newpass) || empty($newpassConf))) {
403                $this->flashMessenger()
404                    ->addMessage('Password fields must not be blank.', 'error');
405            } elseif ($newpass != $newpassConf) {
406                $this->flashMessenger()
407                    ->addMessage('Password fields must match.', 'error');
408            } else {
409                // Connect to database:
410                $connection = $view->driver . '://' . $view->dbrootuser . ':'
411                    . $this->params()->fromPost('dbrootpass') . '@'
412                    . $view->dbhost;
413                try {
414                    $dbName = ($view->driver == 'pgsql')
415                        ? 'template1' : $view->driver;
416                    $db = $this->serviceLocator
417                        ->get(\VuFind\Db\AdapterFactory::class)
418                        ->getAdapterFromConnectionString("{$connection}/{$dbName}");
419                } catch (\Exception $e) {
420                    $this->flashMessenger()
421                        ->addMessage(
422                            'Problem initializing database adapter; '
423                            . 'check for missing ' . $view->driver
424                            . ' library. Details: ' . $e->getMessage(),
425                            'error'
426                        );
427                    return $view;
428                }
429                try {
430                    // Get SQL together
431                    $escapedPass = $skip
432                        ? "'" . addslashes($newpass) . "'"
433                        : $db->getPlatform()->quoteValue($newpass);
434                    $preCommands = $this->getPreCommands($view, $escapedPass);
435                    $postCommands = $this->getPostCommands($view);
436                    $sql = file_get_contents(
437                        APPLICATION_PATH . "/module/VuFind/sql/{$view->driver}.sql"
438                    );
439                    if ($skip) {
440                        $omnisql = '';
441                        foreach ($preCommands as $query) {
442                            $omnisql .= $query . ";\n";
443                        }
444                        $omnisql .= "\n" . $sql . "\n";
445                        foreach ($postCommands as $query) {
446                            $omnisql .= $query . ";\n";
447                        }
448                        $this->getRequest()->getQuery()->set('sql', $omnisql);
449                        return $this->forwardTo('Install', 'showsql');
450                    } else {
451                        foreach ($preCommands as $query) {
452                            $db->query($query, $db::QUERY_MODE_EXECUTE);
453                        }
454                        $dbFactory = $this->serviceLocator
455                            ->get(\VuFind\Db\AdapterFactory::class);
456                        $db = $dbFactory->getAdapterFromConnectionString(
457                            $connection . '/' . $view->dbname
458                        );
459                        $statements = explode(';', $sql);
460                        foreach ($statements as $current) {
461                            // Skip empty sections:
462                            if (strlen(trim($current)) == 0) {
463                                continue;
464                            }
465                            $db->query($current, $db::QUERY_MODE_EXECUTE);
466                        }
467                        foreach ($postCommands as $query) {
468                            $db->query($query, $db::QUERY_MODE_EXECUTE);
469                        }
470                        // If we made it this far, we can update the config file and
471                        // forward back to the home action!
472                        $string = "{$view->driver}://{$view->dbuser}:{$newpass}@"
473                            . $view->dbhost . '/' . $view->dbname;
474                        $config = $this->getForcedLocalConfigPath('config.ini');
475                        $writer = new ConfigWriter($config);
476                        $writer->set('Database', 'database', $string);
477                        if (!$writer->save()) {
478                            return $this->forwardTo('Install', 'fixbasicconfig');
479                        }
480                    }
481                    return $this->redirect()->toRoute('install-home');
482                } catch (\Exception $e) {
483                    $this->flashMessenger()->addMessage($e->getMessage(), 'error');
484                }
485            }
486        }
487        return $view;
488    }
489
490    /**
491     * Get SQL commands needed to set up a particular database before
492     * loading the main SQL file of table definitions.
493     *
494     * @param \Laminas\View\Model $view        View object containing DB settings.
495     * @param string              $escapedPass Password to set for new DB (escaped
496     * appropriately for target database).
497     *
498     * @return array
499     */
500    protected function getPreCommands($view, $escapedPass)
501    {
502        $create = 'CREATE DATABASE ' . $view->dbname;
503        // Special case: PostgreSQL:
504        if ($view->driver == 'pgsql') {
505            $escape = 'ALTER DATABASE ' . $view->dbname
506                . " SET bytea_output='escape'";
507            $cuser = 'CREATE USER ' . $view->dbuser
508                . " WITH PASSWORD {$escapedPass}";
509            $grant = 'GRANT ALL PRIVILEGES ON DATABASE '
510                . "{$view->dbname} TO {$view->dbuser} ";
511            return [$create, $escape, $cuser, $grant];
512        }
513        // Default: MySQL:
514        $user = "CREATE USER '{$view->dbuser}'@'{$view->vufindhost}"
515            . "IDENTIFIED BY {$escapedPass}";
516        $grant = 'GRANT SELECT,INSERT,UPDATE,DELETE ON '
517            . $view->dbname
518            . ".* TO '{$view->dbuser}'@'{$view->vufindhost}"
519            . 'WITH GRANT OPTION';
520        $use = "USE {$view->dbname}";
521        return [$create, $user, $grant, 'FLUSH PRIVILEGES', $use];
522    }
523
524    /**
525     * Get SQL commands needed to set up a particular database after
526     * loading the main SQL file of table definitions.
527     *
528     * @param \Laminas\View\Model $view View object containing DB settings.
529     *
530     * @return array
531     */
532    protected function getPostCommands($view)
533    {
534        // Special case: PostgreSQL:
535        if ($view->driver == 'pgsql') {
536            $grantTables = 'GRANT ALL PRIVILEGES ON ALL TABLES IN '
537                . "SCHEMA public TO {$view->dbuser} ";
538            $grantSequences = 'GRANT ALL PRIVILEGES ON ALL SEQUENCES'
539                . " IN SCHEMA public TO {$view->dbuser} ";
540            return [$grantTables, $grantSequences];
541        }
542        // Default: MySQL:
543        return [];
544    }
545
546    /**
547     * Display captured SQL commands for database action.
548     *
549     * @return mixed
550     */
551    protected function showsqlAction()
552    {
553        $continue = $this->params()->fromPost('continue', 'nope');
554        if ($continue == 'Next') {
555            return $this->redirect()->toRoute('install-home');
556        }
557
558        return $this->createViewModel(
559            ['sql' => $this->params()->fromQuery('sql')]
560        );
561    }
562
563    /**
564     * Check if ILS configuration is appropriate.
565     *
566     * @return array
567     */
568    protected function checkILS()
569    {
570        $config = $this->getConfig();
571        if (in_array($config->Catalog->driver, ['Sample', 'Demo'])) {
572            $status = false;
573        } else {
574            try {
575                $status = 'ils-offline' !== $this->getILS()->getOfflineMode(true);
576            } catch (\Exception $e) {
577                $status = false;
578            }
579        }
580        return ['title' => 'ILS', 'status' => $status, 'fix' => 'fixils'];
581    }
582
583    /**
584     * Display repair instructions for ILS problems.
585     *
586     * @return mixed
587     */
588    public function fixilsAction()
589    {
590        // Process incoming parameter -- user may have selected a new driver:
591        $newDriver = $this->params()->fromPost('driver');
592        if (!empty($newDriver)) {
593            $configPath = $this->getForcedLocalConfigPath('config.ini');
594            $writer = new ConfigWriter($configPath);
595            $writer->set('Catalog', 'driver', $newDriver);
596            if (!$writer->save()) {
597                return $this->forwardTo('Install', 'fixbasicconfig');
598            }
599            // Copy configuration, if applicable:
600            $ilsIni = $this->getBaseConfigFilePath("{$newDriver}.ini");
601            $localIlsIni = $this->getForcedLocalConfigPath("{$newDriver}.ini");
602            if (file_exists($ilsIni) && !file_exists($localIlsIni)) {
603                if (!copy($ilsIni, $localIlsIni)) {
604                    return $this->forwardTo('Install', 'fixbasicconfig');
605                }
606            }
607            return $this->redirect()->toRoute('install-home');
608        }
609
610        // If we got this far, check whether we have an error with a real driver
611        // or if we need to warn the user that they have selected a fake driver:
612        $config = $this->getConfig();
613        $view = $this->createViewModel();
614        if (in_array($config->Catalog->driver, ['Sample', 'Demo'])) {
615            $view->demo = true;
616            // Get a list of available drivers:
617            $dir
618                = opendir(APPLICATION_PATH . '/module/VuFind/src/VuFind/ILS/Driver');
619            $drivers = [];
620            $excludeList = [
621                'Sample.php', 'Demo.php', 'DriverInterface.php', 'PluginManager.php',
622            ];
623            while ($line = readdir($dir)) {
624                if (
625                    stristr($line, '.php') && !in_array($line, $excludeList)
626                    && !str_starts_with($line, 'Abstract')
627                    && !str_ends_with($line, 'Factory.php')
628                    && !str_ends_with($line, 'Trait.php')
629                ) {
630                    $drivers[] = str_replace('.php', '', $line);
631                }
632            }
633            closedir($dir);
634            sort($drivers);
635            $view->drivers = $drivers;
636        } else {
637            $view->configPath = $this->getForcedLocalConfigPath(
638                "{$config->Catalog->driver}.ini"
639            );
640        }
641        return $view;
642    }
643
644    /**
645     * Support method to test the search service
646     *
647     * @return void
648     * @throws \Exception
649     */
650    protected function testSearchService()
651    {
652        // Try to retrieve an arbitrary ID -- this will fail if Solr is down:
653        $searchService = $this->serviceLocator->get(\VuFindSearch\Service::class);
654        $command = new RetrieveCommand('Solr', '1');
655        $searchService->invoke($command)->getResult();
656    }
657
658    /**
659     * Check if the Solr index is working.
660     *
661     * @return array
662     */
663    protected function checkSolr()
664    {
665        try {
666            $this->testSearchService();
667            $status = true;
668        } catch (\Exception $e) {
669            $status = false;
670        }
671        return ['title' => 'Solr', 'status' => $status, 'fix' => 'fixsolr'];
672    }
673
674    /**
675     * Display repair instructions for Solr problems.
676     *
677     * @return mixed
678     */
679    public function fixsolrAction()
680    {
681        // In Windows, localhost may fail -- see if switching to 127.0.0.1 helps:
682        $config = $this->getConfig();
683        $configFile = $this->getForcedLocalConfigPath('config.ini');
684        if (stristr($config->Index->url, 'localhost')) {
685            $newUrl = str_replace('localhost', '127.0.0.1', $config->Index->url);
686            try {
687                $this->testSearchService();
688
689                // If we got this far, the fix worked. Let's write it to disk!
690                $writer = new ConfigWriter($configFile);
691                $writer->set('Index', 'url', $newUrl);
692                if (!$writer->save()) {
693                    return $this->forwardTo('Install', 'fixbasicconfig');
694                }
695                return $this->redirect()->toRoute('install-home');
696            } catch (\Exception $e) {
697                // Didn't work!
698            }
699        }
700
701        // If we got this far, the automatic fix didn't work, so let's just assign
702        // some variables to use in offering troubleshooting advice:
703        $view = $this->createViewModel();
704        $view->rawUrl = $config->Index->url;
705        $view->userUrl = str_replace(
706            ['localhost', '127.0.0.1'],
707            $this->getRequest()->getServer()->get('HTTP_HOST'),
708            $config->Index->url
709        );
710        $view->core = $config->Index->default_core ?? 'biblio';
711        $view->configFile = $configFile;
712        return $view;
713    }
714
715    /**
716     * Check if Security configuration is set.
717     *
718     * @return array
719     */
720    protected function checkSecurity()
721    {
722        return [
723            'title' => 'Security',
724            'status' => $this->hasSecureDatabase(),
725            'fix' => 'fixsecurity',
726        ];
727    }
728
729    /**
730     * Support method for fixsecurityAction(). Returns true if the configuration
731     * was modified, false otherwise.
732     *
733     * @param \Laminas\Config\Config $config Existing VuFind configuration
734     * @param ConfigWriter           $writer Config writer
735     *
736     * @return bool
737     */
738    protected function fixSecurityConfiguration($config, $writer)
739    {
740        $changed = false;
741
742        if (
743            !($config->Authentication->hash_passwords ?? false)
744            || !($config->Authentication->encrypt_ils_password ?? false)
745        ) {
746            $writer->set('Authentication', 'hash_passwords', true);
747            $writer->set('Authentication', 'encrypt_ils_password', true);
748            $changed = true;
749        }
750        // Only rewrite encryption key if we don't already have one:
751        if (empty($config->Authentication->ils_encryption_key)) {
752            [$algorithm, $key] = $this->getSecureAlgorithmAndKey();
753            $writer->set('Authentication', 'ils_encryption_algo', $algorithm);
754            $writer->set('Authentication', 'ils_encryption_key', $key);
755            $changed = true;
756        }
757
758        return $changed;
759    }
760
761    /**
762     * Display repair instructions for Security problems.
763     *
764     * @return mixed
765     */
766    public function fixsecurityAction()
767    {
768        // If the user doesn't want to proceed, abort now:
769        $userConfirmation = $this->params()->fromPost('fix-user-table', 'Unset');
770        if ($userConfirmation == 'No') {
771            $msg = 'Security upgrade aborted.';
772            $this->flashMessenger()->addMessage($msg, 'error');
773            return $this->redirect()->toRoute('install-home');
774        }
775
776        // If we don't need to prompt the user, or if they confirmed, do the fix:
777        $userRows = $this->getDbService(UserServiceInterface::class)->getInsecureRows();
778        $cardRows = $this->getDbService(UserCardServiceInterface::class)->getInsecureRows();
779        if (count($userRows) + count($cardRows) == 0 || $userConfirmation == 'Yes') {
780            return $this->forwardTo('Install', 'performsecurityfix');
781        }
782
783        // If we got this far, we need to ask permission to proceed:
784        $view = $this->createViewModel();
785        $view->confirmUserFix = true;
786        return $view;
787    }
788
789    /**
790     * Perform fix for Security problems.
791     *
792     * @return mixed
793     */
794    public function performsecurityfixAction()
795    {
796        // This can take a while -- don't time out!
797        set_time_limit(0);
798
799        // First, set encryption/hashing to true, and set the key
800        $config = $this->getConfig();
801        $configPath = $this->getForcedLocalConfigPath('config.ini');
802        $writer = new ConfigWriter($configPath);
803        if ($this->fixSecurityConfiguration($config, $writer)) {
804            // Problem writing? Show the user an error:
805            if (!$writer->save()) {
806                return $this->forwardTo('Install', 'fixbasicconfig');
807            }
808
809            // Success? Redirect to this action in order to reload the configuration:
810            return $this->redirect()->toRoute('install-performsecurityfix');
811        }
812
813        // Now we want to loop through the database and update passwords (if
814        // necessary).
815        $ilsAuthenticator = $this->serviceLocator->get(\VuFind\Auth\ILSAuthenticator::class);
816        $userRows = $this->getDbService(UserServiceInterface::class)->getInsecureRows();
817        if (count($userRows) > 0) {
818            $bcrypt = new Bcrypt();
819            foreach ($userRows as $row) {
820                if ($row->password != '') {
821                    $row->pass_hash = $bcrypt->create($row->password);
822                    $row->password = '';
823                }
824                if ($rawPassword = $row->getRawCatPassword()) {
825                    $ilsAuthenticator->saveUserCatalogCredentials($row, $row->getCatUsername(), $rawPassword);
826                } else {
827                    $row->save();
828                }
829            }
830            $msg = count($userRows) . ' user row(s) encrypted.';
831            $this->flashMessenger()->addMessage($msg, 'info');
832        }
833        $cardService = $this->getDbService(UserCardServiceInterface::class);
834        $cardRows = $cardService->getInsecureRows();
835        if (count($cardRows) > 0) {
836            foreach ($cardRows as $row) {
837                $row->setCatPassEnc($ilsAuthenticator->encrypt($row->getRawCatPassword()));
838                $row->setRawCatPassword(null);
839                $cardService->persistEntity($row);
840            }
841            $msg = count($cardRows) . ' user_card row(s) encrypted.';
842            $this->flashMessenger()->addMessage($msg, 'info');
843        }
844        return $this->redirect()->toRoute('install-home');
845    }
846
847    /**
848     * Check if SSL configuration is set properly.
849     *
850     * @return array
851     */
852    public function checkSslCerts()
853    {
854        // Try to retrieve an SSL URL; if we're misconfigured, it will fail.
855        try {
856            $this->serviceLocator->get(\VuFindHttp\HttpService::class)
857                ->get('https://google.com');
858            $status = true;
859        } catch (\VuFindHttp\Exception\RuntimeException $e) {
860            // Any exception means we have a problem!
861            $status = false;
862        }
863
864        return [
865            'title' => 'SSL', 'status' => $status, 'fix' => 'fixsslcerts',
866        ];
867    }
868
869    /**
870     * Display repair instructions for SSL certificate problems.
871     *
872     * @return mixed
873     */
874    public function fixsslcertsAction()
875    {
876        // Bail out if we've fixed the problem:
877        $result = $this->checkSslCerts();
878        if ($result['status'] == true) {
879            $this->flashMessenger()->addMessage('SSL configuration fixed.', 'info');
880            return $this->redirect()->toRoute('install-home');
881        }
882
883        // Find out which test to try next:
884        $try = $this->params()->fromQuery('try', 0);
885
886        // Configurations to test:
887        $configsToTest = [
888            ['sslcapath' => '/etc/ssl/certs'],
889            ['sslcafile' => '/etc/pki/tls/cert.pem'],
890            [], // reset configuration as last attempt
891        ];
892        if (isset($configsToTest[$try])) {
893            return $this->testSslCertConfig($configsToTest[$try], $try);
894        }
895
896        // If we got this far, we can't fix this automatically and must display
897        // a message.
898        $view = $this->createViewModel();
899        return $view;
900    }
901
902    /**
903     * Try switching to a specific SSL configuration.
904     *
905     * @param array $config Setting(s) to add to [Http] section of config.ini.
906     * @param int   $try    Which config index are we trying right now?
907     *
908     * @return \Laminas\Http\Response
909     */
910    protected function testSslCertConfig($config, $try)
911    {
912        $file = $this->getForcedLocalConfigPath('config.ini');
913        $writer = new ConfigWriter($file);
914        // Reset old settings
915        $writer->clear('Http', 'sslcapath');
916        $writer->clear('Http', 'sslcafile');
917        // Load new settings
918        foreach ($config as $setting => $value) {
919            $writer->set('Http', $setting, $value);
920        }
921        if (!$writer->save()) {
922            throw new \Exception('Cannot write config to disk.');
923        }
924
925        // Jump back to fix action so we can check if it worked (and attempt
926        // the next config by incrementing the $try variable, if necessary):
927        return $this->redirect()->toRoute(
928            'install-fixsslcerts',
929            [],
930            ['query' => ['try' => $try + 1]]
931        );
932    }
933
934    /**
935     * Disable auto-configuration.
936     *
937     * @return mixed
938     */
939    public function doneAction()
940    {
941        $config = $this->getForcedLocalConfigPath('config.ini');
942        $writer = new ConfigWriter($config);
943        $writer->set('System', 'autoConfigure', 0);
944        if (!$writer->save()) {
945            return $this->forwardTo('Install', 'fixbasicconfig');
946        }
947        return $this->createViewModel(['configDir' => dirname($config)]);
948    }
949
950    /**
951     * Display summary of installation status
952     *
953     * @return mixed
954     */
955    public function homeAction()
956    {
957        // Perform all checks (based on naming convention):
958        $methods = get_class_methods($this);
959        $checks = [];
960        foreach ($methods as $method) {
961            if (str_starts_with($method, 'check')) {
962                $checks[] = $this->$method();
963            }
964        }
965        return $this->createViewModel(['checks' => $checks]);
966    }
967
968    /**
969     * Get minimal PHP version required for VuFind to run.
970     *
971     * @return string
972     */
973    protected function getMinimalPhpVersion(): string
974    {
975        $composer = $this->getComposerJson();
976        if (empty($composer)) {
977            throw new \Exception('Cannot find composer.json');
978        }
979        $rawVersion = $composer['require']['php']
980            ?? $composer['config']['platform']['php']
981            ?? '';
982        $version = preg_replace('/[^0-9. ]/', '', $rawVersion);
983        if (empty($version) || !preg_match('/^[0-9]/', $version)) {
984            throw new \Exception('Cannot parse PHP version from composer.json');
985        }
986        $versionParts = preg_split('/[. ]/', $version);
987        $versionParts = array_pad($versionParts, 3, '0');
988        return sprintf('%d.%d.%d', ...$versionParts);
989    }
990
991    /**
992     * Get minimal PHP version ID required for VuFind to run.
993     *
994     * @return int
995     */
996    protected function getMinimalPhpVersionId(): int
997    {
998        $version = explode('.', $this->getMinimalPhpVersion());
999        return $version[0] * 10000 + $version[1] * 100 + $version[2];
1000    }
1001
1002    /**
1003     * Get composer.json data as array
1004     *
1005     * @return array
1006     */
1007    protected function getComposerJson(): array
1008    {
1009        try {
1010            $composerJsonFileName = APPLICATION_PATH . '/composer.json';
1011            if (file_exists($composerJsonFileName)) {
1012                return json_decode(file_get_contents($composerJsonFileName), true);
1013            }
1014        } catch (\Throwable $exception) {
1015            return [];
1016        }
1017        return [];
1018    }
1019}