Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 457
0.00% covered (danger)
0.00%
0 / 35
CRAP
0.00% covered (danger)
0.00%
0 / 1
UpgradeController
0.00% covered (danger)
0.00%
0 / 457
0.00% covered (danger)
0.00%
0 / 35
20592
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 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 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 errorAction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 establishversionsAction
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 fixconfigAction
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 getRootDbAdapter
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 hasDatabaseRootCredentials
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 setDbEncodingConfiguration
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 fixVuFindSourceInDatabase
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 fixInvalidUserIdsInSearchTable
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 fixSearchChecksumsInDatabase
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 upgradeMySQL
0.00% covered (danger)
0.00%
0 / 109
0.00% covered (danger)
0.00%
0 / 1
930
 fixdatabaseAction
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
132
 showsqlAction
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 confirmdeprecatedcolumnsAction
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 getdbcredentialsAction
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 fixanonymoustagsAction
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 fixduplicatetagsAction
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 fixmetadataAction
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
56
 getsourcedirAction
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 processSkipParam
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getsourceversionAction
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 performCriticalChecks
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 isSourceDirValid
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 setSourceDir
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSourceDir
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 homeAction
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
182
 resetAction
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 fixshortlinks
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 criticalCheckForInsecureDatabase
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 criticalCheckForBlowfishEncryption
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 criticalFixInsecureDatabaseAction
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 criticalFixBlowfishAction
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * Upgrade Controller
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 * Copyright (C) The National Library of Finland 2016.
10 *
11 * This program is free software; you can redistribute it and/or modify
12 * it under the terms of the GNU General Public License version 2,
13 * as published by the Free Software Foundation.
14 *
15 * This program is distributed in the hope that it will be useful,
16 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 * GNU General Public License for more details.
19 *
20 * You should have received a copy of the GNU General Public License
21 * along with this program; if not, write to the Free Software
22 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
23 *
24 * @category VuFind
25 * @package  Controller
26 * @author   Demian Katz <demian.katz@villanova.edu>
27 * @author   Ere Maijala <ere.maijala@helsinki.fi>
28 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
29 * @link     https://vufind.org Main Site
30 */
31
32namespace VuFind\Controller;
33
34use ArrayObject;
35use Composer\Semver\Comparator;
36use Exception;
37use Laminas\Crypt\BlockCipher;
38use Laminas\Crypt\Symmetric\Openssl;
39use Laminas\Db\Adapter\Adapter;
40use Laminas\Mvc\MvcEvent;
41use Laminas\ServiceManager\ServiceLocatorInterface;
42use Laminas\Session\Container;
43use VuFind\Cache\Manager as CacheManager;
44use VuFind\Config\Upgrade;
45use VuFind\Config\Version;
46use VuFind\Config\Writer;
47use VuFind\Cookie\Container as CookieContainer;
48use VuFind\Cookie\CookieManager;
49use VuFind\Crypt\Base62;
50use VuFind\Db\AdapterFactory;
51use VuFind\Db\Service\ResourceServiceInterface;
52use VuFind\Db\Service\ResourceTagsServiceInterface;
53use VuFind\Db\Service\SearchServiceInterface;
54use VuFind\Db\Service\ShortlinksServiceInterface;
55use VuFind\Db\Service\UserServiceInterface;
56use VuFind\Exception\RecordMissing as RecordMissingException;
57use VuFind\Record\ResourcePopulator;
58use VuFind\Search\Results\PluginManager as ResultsManager;
59use VuFind\Tags\TagsService;
60
61use function count;
62use function dirname;
63use function in_array;
64use function is_string;
65use function strlen;
66
67/**
68 * Class controls VuFind upgrading.
69 *
70 * @category VuFind
71 * @package  Controller
72 * @author   Demian Katz <demian.katz@villanova.edu>
73 * @author   Ere Maijala <ere.maijala@helsinki.fi>
74 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
75 * @link     https://vufind.org Main Site
76 */
77class UpgradeController extends AbstractBase
78{
79    use Feature\ConfigPathTrait;
80    use Feature\SecureDatabaseTrait;
81
82    /**
83     * Cookie container
84     *
85     * @var CookieContainer
86     */
87    protected $cookie;
88
89    /**
90     * Session container
91     *
92     * @var Container
93     */
94    protected $session;
95
96    /**
97     * Are we capturing SQL instead of executing it?
98     *
99     * @var bool
100     */
101    protected $logsql = false;
102
103    /**
104     * Constructor
105     *
106     * @param ServiceLocatorInterface $sm               Service manager
107     * @param CookieManager           $cookieManager    Cookie manager
108     * @param Container               $sessionContainer Session container
109     */
110    public function __construct(
111        ServiceLocatorInterface $sm,
112        CookieManager $cookieManager,
113        Container $sessionContainer
114    ) {
115        parent::__construct($sm);
116
117        // We want to use cookies for tracking the state of the upgrade, since the
118        // session is unreliable -- if the user upgrades a configuration that uses
119        // a different session handler than the default one, we'll lose track of our
120        // upgrade state in the middle of the process!
121        $this->cookie = new CookieContainer('vfup', $cookieManager);
122
123        // ...however, once the configuration piece of the upgrade is done, we can
124        // safely use the session for storing some values. We'll use this for the
125        // temporary storage of root database credentials, since it is unwise to
126        // send such sensitive values around as cookies!
127        $this->session = $sessionContainer;
128
129        // We should also use the session for storing warnings once we know it will
130        // be stable; this will prevent the cookies from getting too big.
131        if (!isset($this->session->warnings)) {
132            $this->session->warnings = new ArrayObject();
133        }
134    }
135
136    /**
137     * Use preDispatch event to block access when appropriate.
138     *
139     * @param MvcEvent $e Event object
140     *
141     * @return void
142     */
143    public function validateAutoConfigureConfig(MvcEvent $e)
144    {
145        // If auto-configuration is disabled, prevent any other action from being
146        // accessed:
147        $config = $this->getConfig();
148        if (
149            !isset($config->System->autoConfigure)
150            || !$config->System->autoConfigure
151        ) {
152            $routeMatch = $e->getRouteMatch();
153            $routeMatch->setParam('action', 'disabled');
154        }
155    }
156
157    /**
158     * Register the default events for this controller
159     *
160     * @return void
161     */
162    protected function attachDefaultListeners()
163    {
164        parent::attachDefaultListeners();
165        $events = $this->getEventManager();
166        $events->attach(
167            MvcEvent::EVENT_DISPATCH,
168            [$this, 'validateAutoConfigureConfig'],
169            1000
170        );
171    }
172
173    /**
174     * Display disabled message.
175     *
176     * @return mixed
177     */
178    public function disabledAction()
179    {
180        $view = $this->createViewModel();
181        $view->setTemplate('install/disabled');
182        return $view;
183    }
184
185    /**
186     * Display a fatal error message.
187     *
188     * @return mixed
189     */
190    public function errorAction()
191    {
192        // Just display template
193        return $this->createViewModel();
194    }
195
196    /**
197     * Figure out which version(s) are being used.
198     *
199     * @return mixed
200     * @throws Exception
201     */
202    public function establishversionsAction()
203    {
204        $this->cookie->newVersion = Version::getBuildVersion();
205        $this->cookie->oldVersion = Version::getBuildVersion($this->getSourceDir());
206
207        // Block upgrade when encountering common errors:
208        if (empty($this->cookie->oldVersion)) {
209            $this->flashMessenger()
210                ->addMessage('Cannot determine source version.', 'error');
211            unset($this->cookie->oldVersion);
212            return $this->forwardTo('Upgrade', 'Error');
213        }
214        if (empty($this->cookie->newVersion)) {
215            $this->flashMessenger()
216                ->addMessage('Cannot determine destination version.', 'error');
217            unset($this->cookie->newVersion);
218            return $this->forwardTo('Upgrade', 'Error');
219        }
220        if ($this->cookie->newVersion == $this->cookie->oldVersion) {
221            $this->flashMessenger()
222                ->addMessage('Cannot upgrade version to itself.', 'error');
223            unset($this->cookie->newVersion);
224            return $this->forwardTo('Upgrade', 'Error');
225        }
226
227        // If we got this far, everything is okay:
228        return $this->forwardTo('Upgrade', 'Home');
229    }
230
231    /**
232     * Upgrade the configuration files.
233     *
234     * @return mixed
235     */
236    public function fixconfigAction()
237    {
238        $localConfig = dirname($this->getForcedLocalConfigPath('config.ini'));
239        $confDir = $this->cookie->oldVersion < 2
240            ? $this->getSourceDir() . '/web/conf'
241            : $localConfig;
242        $upgrader = new Upgrade(
243            $this->cookie->oldVersion,
244            $this->cookie->newVersion,
245            $confDir,
246            dirname($this->getBaseConfigFilePath('config.ini')),
247            $localConfig
248        );
249        try {
250            $upgrader->run();
251            $this->cookie->warnings = $upgrader->getWarnings();
252            $this->cookie->configOkay = true;
253            return $this->forwardTo('Upgrade', 'Home');
254        } catch (Exception $e) {
255            $extra = is_a($e, \VuFind\Exception\FileAccess::class)
256                ? '  Check file permissions.' : '';
257            $this->flashMessenger()->addMessage(
258                'Config upgrade failed: ' . $e->getMessage() . $extra,
259                'error'
260            );
261            return $this->forwardTo('Upgrade', 'Error');
262        }
263    }
264
265    /**
266     * Get a database adapter for root access using credentials in session.
267     *
268     * @return Adapter
269     */
270    protected function getRootDbAdapter()
271    {
272        // Use static cache to avoid loading adapter more than once on
273        // subsequent calls.
274        static $adapter = false;
275        if (!$adapter) {
276            $factory = $this->serviceLocator->get(AdapterFactory::class);
277            $adapter = $factory->getAdapter(
278                $this->session->dbRootUser,
279                $this->session->dbRootPass
280            );
281        }
282        return $adapter;
283    }
284
285    /**
286     * Do we have root DB credentials stored?
287     *
288     * @return bool
289     */
290    protected function hasDatabaseRootCredentials()
291    {
292        return isset($this->session->dbRootUser)
293            && isset($this->session->dbRootPass);
294    }
295
296    /**
297     * Configure the database encoding.
298     *
299     * @param string $charset Encoding setting to use.
300     *
301     * @throws Exception
302     * @return void
303     */
304    protected function setDbEncodingConfiguration($charset)
305    {
306        $config = $this->getForcedLocalConfigPath('config.ini');
307        $writer = new Writer($config);
308        $writer->set('Database', 'charset', $charset);
309        if (!$writer->save()) {
310            throw new Exception('Problem writing DB encoding to config.ini');
311        }
312    }
313
314    /**
315     * Support method for fixdatabaseAction() -- clean up legacy 'VuFind'
316     * source values in the database.
317     *
318     * @return void
319     */
320    protected function fixVuFindSourceInDatabase()
321    {
322        if ($count = $this->getDbService(ResourceServiceInterface::class)->renameSource('VuFind', 'Solr')) {
323            $this->session->warnings
324                ->append('Converted ' . $count . ' legacy "VuFind" source value(s) in resource table');
325        }
326    }
327
328    /**
329     * Support method for fixdatabaseAction() -- clean up invalid user ID
330     * values in the search table.
331     *
332     * @return void
333     */
334    protected function fixInvalidUserIdsInSearchTable(): void
335    {
336        $count = $this->getDbService(SearchServiceInterface::class)->cleanUpInvalidUserIds();
337        if ($count) {
338            $this->session->warnings->append("Converted $count invalid user_id values in search table");
339        }
340    }
341
342    /**
343     * Support method for fixdatabaseAction() -- add checksums to search table rows.
344     *
345     * @return void
346     */
347    protected function fixSearchChecksumsInDatabase()
348    {
349        $manager = $this->serviceLocator->get(ResultsManager::class);
350        $searchService = $this->getDbService(SearchServiceInterface::class);
351        $searchRows = $searchService->getSavedSearchesWithMissingChecksums();
352        if (count($searchRows) > 0) {
353            foreach ($searchRows as $searchRow) {
354                $searchObj = $searchRow->getSearchObject()?->deminify($manager);
355                if (!$searchObj) {
356                    throw new Exception("Missing search data for row {$searchRow->getId()}.");
357                }
358                $url = $searchObj->getUrlQuery()->getParams();
359                $checksum = crc32($url) & 0xFFFFFFF;
360                $searchRow->setChecksum($checksum);
361                $searchService->persistEntity($searchRow);
362            }
363            $this->session->warnings->append(
364                'Added checksum to ' . count($searchRows) . ' rows in search table'
365            );
366        }
367    }
368
369    /**
370     * Attempt to perform a MySQL upgrade; return either a string containing SQL
371     * (if we are in "log SQL" mode), an empty string (if we are successful but
372     * not logging SQL) or a Laminas object representing forward/redirect (if we
373     * need to obtain user input).
374     *
375     * @param Adapter $adapter Database adapter
376     *
377     * @return mixed
378     * @throws Exception
379     */
380    protected function upgradeMySQL($adapter)
381    {
382        $sql = '';
383
384        // Set up the helper with information from our SQL file:
385        $this->dbUpgrade()
386            ->setAdapter($adapter)
387            ->loadSql(APPLICATION_PATH . '/module/VuFind/sql/mysql.sql');
388
389        // Check for deprecated columns. We prompt the user for action on this, so
390        // let's get that settled before doing further work.
391        $deprecatedColumns = $this->dbUpgrade()->getDeprecatedColumns();
392        if (!empty($deprecatedColumns)) {
393            if (!empty($this->session->deprecatedColumnsAction)) {
394                if ($this->session->deprecatedColumnsAction === 'delete') {
395                    // Only manipulate DB if we're not in logging mode:
396                    if (!$this->logsql) {
397                        if (!$this->hasDatabaseRootCredentials()) {
398                            return $this->forwardTo('Upgrade', 'GetDbCredentials');
399                        }
400                        $this->dbUpgrade()->setAdapter($this->getRootDbAdapter());
401                        $this->session->warnings->append(
402                            'Removed deprecated column(s) from table(s): '
403                            . implode(', ', array_keys($deprecatedColumns))
404                        );
405                    }
406                    $sql .= $this->dbUpgrade()
407                        ->removeDeprecatedColumns($deprecatedColumns, $this->logsql);
408                }
409            } else {
410                return $this->forwardTo('Upgrade', 'ConfirmDeprecatedColumns');
411            }
412        }
413
414        // Check for missing tables. Note that we need to finish dealing with
415        // missing tables before we proceed to the missing columns check, or else
416        // the missing tables will cause fatal errors during the column test.
417        $missingTables = $this->dbUpgrade()->getMissingTables();
418        if (!empty($missingTables)) {
419            // Only manipulate DB if we're not in logging mode:
420            if (!$this->logsql) {
421                if (!$this->hasDatabaseRootCredentials()) {
422                    return $this->forwardTo('Upgrade', 'GetDbCredentials');
423                }
424                $this->dbUpgrade()->setAdapter($this->getRootDbAdapter());
425                $this->session->warnings->append(
426                    'Created missing table(s): ' . implode(', ', $missingTables)
427                );
428            }
429            $sql .= $this->dbUpgrade()
430                ->createMissingTables($missingTables, $this->logsql);
431        }
432
433        // Check for missing columns.
434        $mT = $this->logsql ? $missingTables : [];
435        $missingCols = $this->dbUpgrade()->getMissingColumns($mT);
436        if (!empty($missingCols)) {
437            // Only manipulate DB if we're not in logging mode:
438            if (!$this->logsql) {
439                if (!$this->hasDatabaseRootCredentials()) {
440                    return $this->forwardTo('Upgrade', 'GetDbCredentials');
441                }
442                $this->dbUpgrade()->setAdapter($this->getRootDbAdapter());
443                $this->session->warnings->append(
444                    'Added column(s) to table(s): '
445                    . implode(', ', array_keys($missingCols))
446                );
447            }
448            $sql .= $this->dbUpgrade()
449                ->createMissingColumns($missingCols, $this->logsql);
450        }
451
452        // Check for modified columns.
453        $mC = $this->logsql ? $missingCols : [];
454        $modifiedCols = $this->dbUpgrade()->getModifiedColumns($mT, $mC);
455        if (!empty($modifiedCols)) {
456            // Only manipulate DB if we're not in logging mode:
457            if (!$this->logsql) {
458                if (!$this->hasDatabaseRootCredentials()) {
459                    return $this->forwardTo('Upgrade', 'GetDbCredentials');
460                }
461                $this->dbUpgrade()->setAdapter($this->getRootDbAdapter());
462                $this->session->warnings->append(
463                    'Modified column(s) in table(s): '
464                    . implode(', ', array_keys($modifiedCols))
465                );
466            }
467            $sql .= $this->dbUpgrade()
468                ->updateModifiedColumns($modifiedCols, $this->logsql);
469        }
470
471        // Check for missing constraints.
472        $missingConstraints = $this->dbUpgrade()->getMissingConstraints($mT);
473        if (!empty($missingConstraints)) {
474            // Only manipulate DB if we're not in logging mode:
475            if (!$this->logsql) {
476                if (!$this->hasDatabaseRootCredentials()) {
477                    return $this->forwardTo('Upgrade', 'GetDbCredentials');
478                }
479                $this->dbUpgrade()->setAdapter($this->getRootDbAdapter());
480                $this->session->warnings->append(
481                    'Added constraint(s) to table(s): '
482                    . implode(', ', array_keys($missingConstraints))
483                );
484            }
485            $sql .= $this->dbUpgrade()
486                ->createMissingConstraints($missingConstraints, $this->logsql);
487        }
488
489        // Check for modified constraints.
490        $mC = $this->logsql ? $missingConstraints : [];
491        $modifiedConstraints = $this->dbUpgrade()->getModifiedConstraints($mT, $mC);
492        if (!empty($modifiedConstraints)) {
493            // Only manipulate DB if we're not in logging mode:
494            if (!$this->logsql) {
495                if (!$this->hasDatabaseRootCredentials()) {
496                    return $this->forwardTo('Upgrade', 'GetDbCredentials');
497                }
498                $this->dbUpgrade()->setAdapter($this->getRootDbAdapter());
499                $this->session->warnings->append(
500                    'Modified constraint(s) in table(s): '
501                    . implode(', ', array_keys($modifiedConstraints))
502                );
503            }
504            $sql .= $this->dbUpgrade()
505                ->updateModifiedConstraints($modifiedConstraints, $this->logsql);
506        }
507
508        // Check for modified keys.
509        $modifiedKeys = $this->dbUpgrade()->getModifiedKeys($mT);
510        if (!empty($modifiedKeys)) {
511            // Only manipulate DB if we're not in logging mode:
512            if (!$this->logsql) {
513                if (!$this->hasDatabaseRootCredentials()) {
514                    return $this->forwardTo('Upgrade', 'GetDbCredentials');
515                }
516                $this->dbUpgrade()->setAdapter($this->getRootDbAdapter());
517                $this->session->warnings->append(
518                    'Modified key(s) in table(s): '
519                    . implode(', ', array_keys($modifiedKeys))
520                );
521            }
522            $sql .= $this->dbUpgrade()
523                ->updateModifiedKeys($modifiedKeys, $this->logsql);
524        }
525
526        // Check for character set and collation problems.
527        $colProblems = $this->dbUpgrade()->getCharsetAndCollationProblems();
528        if (!empty($colProblems)) {
529            if (!$this->logsql) {
530                if (!$this->hasDatabaseRootCredentials()) {
531                    return $this->forwardTo('Upgrade', 'GetDbCredentials');
532                }
533                $this->dbUpgrade()->setAdapter($this->getRootDbAdapter());
534                $this->session->warnings->append(
535                    'Modified character set(s)/collation(s) in table(s): '
536                    . implode(', ', array_keys($colProblems))
537                );
538            }
539            $sql .= $this->dbUpgrade()
540                ->fixCharsetAndCollationProblems($colProblems, $this->logsql);
541            $this->setDbEncodingConfiguration('utf8mb4');
542        }
543
544        // Don't keep DB credentials in session longer than necessary:
545        unset($this->session->dbRootUser);
546        unset($this->session->dbRootPass);
547
548        return $sql;
549    }
550
551    /**
552     * Upgrade the database.
553     *
554     * @return mixed
555     */
556    public function fixdatabaseAction()
557    {
558        try {
559            // If we haven't already tried it, attempt a structure update:
560            if (!isset($this->session->sql)) {
561                // If this is a MySQL connection, we can do an automatic upgrade;
562                // if VuFind is using a different database, we have to prompt the
563                // user to check the migrations directory and upgrade manually.
564                $adapter = $this->serviceLocator
565                    ->get(Adapter::class);
566                $platform = $adapter->getDriver()->getDatabasePlatformName();
567                if (strtolower($platform) == 'mysql') {
568                    $upgradeResult = $this->upgradeMySQL($adapter);
569                    if (!is_string($upgradeResult)) {
570                        return $upgradeResult;
571                    }
572                    $this->session->sql = $upgradeResult;
573                } else {
574                    $this->session->sql = '';
575                    $this->session->warnings->append(
576                        'Automatic database upgrade not supported for ' . $platform
577                        . '. Check for manual migration scripts in the '
578                        . '$VUFIND_HOME/module/VuFind/sql/migrations directory.'
579                    );
580                }
581            }
582
583            // Now that database structure is addressed, we can fix database
584            // content -- the checks below should be platform-independent.
585
586            // Check for legacy tag bugs:
587            $anonymousTags = $this->getDbService(ResourceTagsServiceInterface::class)->getAnonymousCount();
588            if ($anonymousTags > 0 && !isset($this->cookie->skipAnonymousTags)) {
589                $this->getRequest()->getQuery()->set('anonymousCnt', $anonymousTags);
590                return $this->redirect()->toRoute('upgrade-fixanonymoustags');
591            }
592            $dupeTags = $this->serviceLocator->get(TagsService::class)->getDuplicateTags();
593            if (count($dupeTags) > 0 && !isset($this->cookie->skipDupeTags)) {
594                return $this->redirect()->toRoute('upgrade-fixduplicatetags');
595            }
596
597            // fix shortlinks
598            $this->fixshortlinks();
599
600            // Clean up the "VuFind" source, if necessary.
601            $this->fixVuFindSourceInDatabase();
602
603            // Fix invalid user IDs in search table, if necessary.
604            $this->fixInvalidUserIdsInSearchTable();
605        } catch (Exception $e) {
606            $this->flashMessenger()->addMessage(
607                'Database upgrade failed: ' . $e->getMessage(),
608                'error'
609            );
610            return $this->forwardTo('Upgrade', 'Error');
611        }
612
613        // Add checksums to all saved searches but catch exceptions (e.g. in case
614        // column checksum does not exist yet because of sqllog).
615        try {
616            $this->fixSearchChecksumsInDatabase();
617        } catch (Exception $e) {
618            $this->session->warnings->append(
619                'Could not fix checksums in table search - maybe column ' .
620                'checksum is missing? Exception thrown with ' .
621                'message: ' . $e->getMessage()
622            );
623        }
624
625        $this->cookie->databaseOkay = true;
626        if (!empty($this->session->sql)) {
627            return $this->forwardTo('Upgrade', 'ShowSql');
628        }
629        return $this->redirect()->toRoute('upgrade-home');
630    }
631
632    /**
633     * Prompt the user for database credentials.
634     *
635     * @return mixed
636     */
637    public function showsqlAction()
638    {
639        $continue = $this->params()->fromPost('continue', 'nope');
640        if ($continue == 'Next') {
641            unset($this->session->sql);
642            return $this->redirect()->toRoute('upgrade-home');
643        }
644
645        return $this->createViewModel(['sql' => $this->session->sql]);
646    }
647
648    /**
649     * Prompt the user to confirm removal of deprecated columns.
650     *
651     * @return mixed
652     */
653    public function confirmdeprecatedcolumnsAction()
654    {
655        if ($action = $this->params()->fromQuery('action')) {
656            if ($action === 'keep' || $action === 'delete') {
657                $this->session->deprecatedColumnsAction = $action;
658                return $this->redirect()->toRoute('upgrade-fixdatabase');
659            }
660        }
661        $deprecated = $this->dbUpgrade()->getDeprecatedColumns();
662        return $this->createViewModel(compact('deprecated'));
663    }
664
665    /**
666     * Prompt the user for database credentials.
667     *
668     * @return mixed
669     */
670    public function getdbcredentialsAction()
671    {
672        $print = $this->params()->fromPost('printsql', 'nope');
673        if ($print == 'Skip') {
674            $this->logsql = true;
675            return $this->forwardTo('Upgrade', 'FixDatabase');
676        } else {
677            $dbrootuser = $this->params()->fromPost('dbrootuser', 'root');
678
679            // Process form submission:
680            if ($this->formWasSubmitted()) {
681                $pass = $this->params()->fromPost('dbrootpass');
682
683                // Test the connection:
684                try {
685                    // Query a table known to exist
686                    $factory = $this->serviceLocator
687                        ->get(AdapterFactory::class);
688                    $db = $factory->getAdapter($dbrootuser, $pass);
689                    $db->query('SELECT * FROM user;');
690                    $this->session->dbRootUser = $dbrootuser;
691                    $this->session->dbRootPass = $pass;
692                    return $this->forwardTo('Upgrade', 'FixDatabase');
693                } catch (Exception $e) {
694                    $this->flashMessenger()->addMessage(
695                        'Could not connect; please try again.',
696                        'error'
697                    );
698                }
699            }
700        }
701
702        return $this->createViewModel(['dbrootuser' => $dbrootuser]);
703    }
704
705    /**
706     * Prompt the user about fixing anonymous tags.
707     *
708     * @return mixed
709     */
710    public function fixanonymoustagsAction()
711    {
712        // Handle skip action:
713        if (strlen($this->params()->fromPost('skip', '')) > 0) {
714            $this->cookie->skipAnonymousTags = true;
715            return $this->forwardTo('Upgrade', 'FixDatabase');
716        }
717
718        // Handle submit action:
719        if ($this->formWasSubmitted()) {
720            $username = $this->params()->fromPost('username');
721            if (empty($username)) {
722                $this->flashMessenger()
723                    ->addMessage('Username must not be empty.', 'error');
724            } else {
725                $user = $this->getDbService(UserServiceInterface::class)->getUserByUsername($username);
726                if (!$user) {
727                    $this->flashMessenger()->addMessage("User {$username} not found.", 'error');
728                } else {
729                    $this->getDbService(ResourceTagsServiceInterface::class)->assignAnonymousTags($user);
730                    $this->session->warnings->append(
731                        "Assigned all anonymous tags to {$user->getUsername()}."
732                    );
733                    return $this->forwardTo('Upgrade', 'FixDatabase');
734                }
735            }
736        }
737
738        return $this->createViewModel(
739            [
740                'anonymousTags' => $this->params()->fromQuery('anonymousCnt'),
741            ]
742        );
743    }
744
745    /**
746     * Prompt the user about fixing duplicate tags.
747     *
748     * @return mixed
749     */
750    public function fixduplicatetagsAction()
751    {
752        // Handle skip action:
753        if (strlen($this->params()->fromPost('skip', '')) > 0) {
754            $this->cookie->skipDupeTags = true;
755            return $this->forwardTo('Upgrade', 'FixDatabase');
756        }
757
758        // Handle submit action:
759        if ($this->formWasSubmitted()) {
760            $this->serviceLocator->get(TagsService::class)->fixDuplicateTags();
761            return $this->forwardTo('Upgrade', 'FixDatabase');
762        }
763
764        return $this->createViewModel();
765    }
766
767    /**
768     * Fix missing metadata in the resource table.
769     *
770     * @return mixed
771     * @throws Exception
772     */
773    public function fixmetadataAction()
774    {
775        // User requested skipping this step?  No need to do further work:
776        if (strlen($this->params()->fromPost('skip', '')) > 0) {
777            $this->cookie->metadataOkay = true;
778            return $this->forwardTo('Upgrade', 'Home');
779        }
780
781        // This can take a while -- don't time out!
782        set_time_limit(0);
783
784        // Check for problems:
785        $resourceService = $this->getDbService(ResourceServiceInterface::class);
786        $problems = $resourceService->findMissingMetadata();
787
788        // No problems?  We're done here!
789        if (count($problems) == 0) {
790            $this->cookie->metadataOkay = true;
791            return $this->forwardTo('Upgrade', 'Home');
792        }
793
794        // Process submit button:
795        if ($this->formWasSubmitted()) {
796            $resourcePopulator = $this->serviceLocator->get(ResourcePopulator::class);
797            foreach ($problems as $problem) {
798                $recordId = $problem->getRecordId();
799                $source = $problem->getSource();
800                try {
801                    $driver = $this->getRecordLoader()->load($recordId, $source);
802                    $resourceService->persistEntity(
803                        $resourcePopulator->assignMetadata($problem, $driver)
804                    );
805                } catch (RecordMissingException $e) {
806                    $this->session->warnings->append(
807                        "Unable to load metadata for record {$source}:{$recordId}"
808                    );
809                } catch (\Exception $e) {
810                    $this->session->warnings->append(
811                        "Problem saving metadata updates for record {$source}:{$recordId}"
812                    );
813                }
814            }
815            $this->cookie->metadataOkay = true;
816            return $this->forwardTo('Upgrade', 'Home');
817        }
818    }
819
820    /**
821     * Prompt the user for a source directory (to upgrade from 1.x).
822     *
823     * @return mixed
824     */
825    public function getsourcedirAction()
826    {
827        // Process form submission:
828        $dir = $this->params()->fromPost('sourcedir');
829        if (!empty($dir)) {
830            if (!$this->isSourceDirValid($dir)) {
831                $this->flashMessenger()
832                    ->addMessage($dir . ' does not exist.', 'error');
833            } elseif (!file_exists($dir . '/build.xml')) {
834                $this->flashMessenger()->addMessage(
835                    'Could not find build.xml in source directory;'
836                    . ' upgrade does not support VuFind versions prior to 1.1.',
837                    'error'
838                );
839            } else {
840                $this->setSourceDir(rtrim($dir, '\/'));
841                // Clear out request to avoid infinite loop:
842                $this->getRequest()->getPost()->set('sourcedir', '');
843                return $this->forwardTo('Upgrade', 'Home');
844            }
845        }
846
847        return $this->createViewModel();
848    }
849
850    /**
851     * Make sure we only skip the actions the user wants us to.
852     *
853     * @return void
854     */
855    protected function processSkipParam()
856    {
857        $skip = $this->params()->fromPost('skip', []);
858        foreach (['config', 'database', 'metadata'] as $action) {
859            $this->cookie->{$action . 'Okay'} = in_array($action, (array)$skip);
860        }
861    }
862
863    /**
864     * Prompt the user for a source version (to upgrade from 2.x+).
865     *
866     * @return mixed
867     * @throws Exception
868     */
869    public function getsourceversionAction()
870    {
871        // Process form submission:
872        $version = $this->params()->fromPost('sourceversion');
873        if (!empty($version)) {
874            $this->cookie->newVersion = $newVersion = Version::getBuildVersion();
875            if (Comparator::lessThan($version, '2.0')) {
876                $this->flashMessenger()
877                    ->addMessage('Illegal version number.', 'error');
878            } elseif (Comparator::greaterThanOrEqualTo($version, $newVersion)) {
879                $this->flashMessenger()->addMessage(
880                    "Source version must be less than {$newVersion}.",
881                    'error'
882                );
883            } else {
884                $this->cookie->oldVersion = $version;
885                $this->setSourceDir(realpath(APPLICATION_PATH));
886                // Clear out request to avoid infinite loop:
887                $this->getRequest()->getPost()->set('sourceversion', '');
888                $this->processSkipParam();
889                return $this->forwardTo('Upgrade', 'Home');
890            }
891        }
892
893        // If we got this far, we need to send the user back to the form:
894        return $this->forwardTo('Upgrade', 'GetSourceDir');
895    }
896
897    /**
898     * Organize and run critical, blocking checks
899     *
900     * @return string|null
901     */
902    protected function performCriticalChecks()
903    {
904        // Run through a series of checks to be sure there are no critical issues.
905        return $this->criticalCheckForInsecureDatabase()
906            ?? $this->criticalCheckForBlowfishEncryption()
907            ?? null;
908    }
909
910    /**
911     * Validate a source directory string.
912     *
913     * @param string $dir Directory string to check
914     *
915     * @return bool
916     */
917    protected function isSourceDirValid(string $dir): bool
918    {
919        // Prevent abuse of stream wrappers:
920        if (empty($dir) || str_contains($dir, '://')) {
921            return false;
922        }
923        return is_dir($dir);
924    }
925
926    /**
927     * Set the source directory for the upgrade
928     *
929     * @param string $dir Directory to set
930     *
931     * @return void
932     */
933    protected function setSourceDir(string $dir): void
934    {
935        $this->cookie->sourceDir = $dir;
936    }
937
938    /**
939     * Get the source directory for the upgrade
940     *
941     * @param bool $validate Should we validate the directory?
942     *
943     * @return string
944     */
945    protected function getSourceDir($validate = true): string
946    {
947        $sourceDir = $this->cookie->sourceDir ?? '';
948        if ($validate && !$this->isSourceDirValid($sourceDir)) {
949            throw new \Exception('Unexpected source directory value!');
950        }
951        return $sourceDir;
952    }
953
954    /**
955     * Display summary of installation status
956     *
957     * @return mixed
958     */
959    public function homeAction()
960    {
961        // If the cache is messed up, nothing is going to work right -- check that
962        // first:
963        $cache = $this->serviceLocator->get(CacheManager::class);
964        if ($cache->hasDirectoryCreationError()) {
965            return $this->redirect()->toRoute('install-fixcache');
966        }
967
968        // First find out which version we are upgrading:
969        if (!$this->isSourceDirValid($this->getSourceDir(false))) {
970            return $this->forwardTo('Upgrade', 'GetSourceDir');
971        }
972
973        // Next figure out which version(s) are involved:
974        if (
975            !isset($this->cookie->oldVersion)
976            || !isset($this->cookie->newVersion)
977        ) {
978            return $this->forwardTo('Upgrade', 'EstablishVersions');
979        }
980
981        // Check for critical upgrades
982        $criticalFixForward = $this->performCriticalChecks() ?? null;
983        if ($criticalFixForward !== null) {
984            return $this->forwardTo('Upgrade', $criticalFixForward);
985        }
986
987        // Now make sure we have a configuration file ready:
988        if (!isset($this->cookie->configOkay) || !$this->cookie->configOkay) {
989            return $this->redirect()->toRoute('upgrade-fixconfig');
990        }
991
992        // Now make sure the database is up to date:
993        if (!isset($this->cookie->databaseOkay) || !$this->cookie->databaseOkay) {
994            return $this->redirect()->toRoute('upgrade-fixdatabase');
995        }
996
997        // Check for missing metadata in the resource table; note that we do a
998        // redirect rather than a forward here so that a submit button clicked
999        // in the database action doesn't cause the metadata action to also submit!
1000        if (!isset($this->cookie->metadataOkay) || !$this->cookie->metadataOkay) {
1001            return $this->redirect()->toRoute('upgrade-fixmetadata');
1002        }
1003
1004        // We're finally done -- display any warnings that we collected during
1005        // the process.
1006        $allWarnings = array_merge(
1007            $this->cookie->warnings ?? [],
1008            (array)$this->session->warnings
1009        );
1010        foreach ($allWarnings as $warning) {
1011            $this->flashMessenger()->addMessage($warning, 'info');
1012        }
1013
1014        return $this->createViewModel(
1015            [
1016                'configDir'
1017                    => dirname($this->getForcedLocalConfigPath('config.ini')),
1018                'importDir' => LOCAL_OVERRIDE_DIR . '/import',
1019                'oldVersion' => $this->cookie->oldVersion,
1020            ]
1021        );
1022    }
1023
1024    /**
1025     * Start over with the upgrade process in case of an error.
1026     *
1027     * @return mixed
1028     */
1029    public function resetAction()
1030    {
1031        foreach (array_keys($this->cookie->getAllValues()) as $k) {
1032            unset($this->cookie->$k);
1033        }
1034        $storage = $this->session->getManager()->getStorage();
1035        $storage[$this->session->getName()]
1036            = new ArrayObject([], ArrayObject::ARRAY_AS_PROPS);
1037        return $this->forwardTo('Upgrade', 'Home');
1038    }
1039
1040    /**
1041     * Generate base62 encoding to migrate old shortlinks
1042     *
1043     * @throws Exception
1044     *
1045     * @return void
1046     */
1047    protected function fixshortlinks()
1048    {
1049        $shortlinks = $this->getDbService(ShortlinksServiceInterface::class);
1050        $base62 = new Base62();
1051
1052        try {
1053            $results = $shortlinks->getShortLinksWithMissingHashes();
1054
1055            foreach ($results as $result) {
1056                $result->setHash($base62->encode($result->getId()));
1057                $shortlinks->persistEntity($result);
1058            }
1059
1060            if (count($results) > 0) {
1061                $this->session->warnings->append(
1062                    'Added hash value(s) to ' . count($results) . ' short links.'
1063                );
1064            }
1065        } catch (Exception $e) {
1066            $this->session->warnings->append(
1067                'Could not fix hashes in table shortlinks - maybe column ' .
1068                'hash is missing? Exception thrown with ' .
1069                'message: ' . $e->getMessage()
1070            );
1071        }
1072    }
1073
1074    /**
1075     * Check for insecure database settings
1076     *
1077     * @return string|null
1078     */
1079    protected function criticalCheckForInsecureDatabase()
1080    {
1081        if (!empty($this->cookie->ignoreInsecureDb)) {
1082            return null;
1083        }
1084        return $this->hasSecureDatabase() ? null : 'CriticalFixInsecureDatabase';
1085    }
1086
1087    /**
1088     * Check for deprecated and insecure use of blowfish encryption
1089     *
1090     * @return string|null
1091     */
1092    protected function criticalCheckForBlowfishEncryption()
1093    {
1094        $config = $this->getConfig();
1095        $encryptionEnabled = $config->Authentication->encrypt_ils_password ?? false;
1096        $algo = $config->Authentication->ils_encryption_algo ?? 'blowfish';
1097        return ($encryptionEnabled && $algo === 'blowfish')
1098            ? 'CriticalFixBlowfish' : null;
1099    }
1100
1101    /**
1102     * Lead users through the steps required to fix an insecure database
1103     *
1104     * @return mixed
1105     */
1106    public function criticalFixInsecureDatabaseAction()
1107    {
1108        if ($this->params()->fromQuery('ignore')) {
1109            $this->cookie->ignoreInsecureDb = 1;
1110            return $this->redirect()->toRoute('upgrade-home');
1111        }
1112        return $this->createViewModel();
1113    }
1114
1115    /**
1116     * Lead users through the steps required to replace blowfish quickly and easily
1117     *
1118     * @return mixed
1119     */
1120    public function criticalFixBlowfishAction()
1121    {
1122        // Test that blowfish is still working
1123        $blowfishIsWorking = true;
1124        try {
1125            $newcipher = new BlockCipher(new Openssl(['algorithm' => 'blowfish']));
1126            $newcipher->setKey('akeyforatest');
1127            $newcipher->encrypt('youfoundtheeasteregg!');
1128        } catch (Exception $e) {
1129            $blowfishIsWorking = false;
1130        }
1131
1132        // Get new settings
1133        [$newAlgorithm, $exampleKey] = $this->getSecureAlgorithmAndKey();
1134        return $this->createViewModel(
1135            compact('newAlgorithm', 'exampleKey', 'blowfishIsWorking')
1136        );
1137    }
1138}