Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
56.24% covered (warning)
56.24%
257 / 457
21.21% covered (danger)
21.21%
7 / 33
CRAP
0.00% covered (danger)
0.00%
0 / 1
InstallCommand
56.24% covered (warning)
56.24%
257 / 457
21.21% covered (danger)
21.21%
7 / 33
1807.40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
1 / 1
1
 writeFileToDisk
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getWindowsApacheMessage
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getLinuxApacheMessage
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
90
 getApacheLocation
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 validateBasePath
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 validateSolrPort
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getBasePath
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
4.32
 getSolrPort
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
4.32
 initializeOverrideDir
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getOverrideDir
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
4.47
 validateModules
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 validateModule
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 getModule
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
3.01
 getMultisiteMode
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 validateHost
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getHost
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getInput
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 backUpFile
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
8.74
 buildApacheConfig
41.03% covered (danger)
41.03%
16 / 39
0.00% covered (danger)
0.00%
0 / 1
25.61
 getEnvironmentVariables
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 buildUnixEnvironment
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 buildWindowsConfig
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 buildImportConfig
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
3.01
 buildDirs
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 buildModules
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
14.11
 buildModule
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 failWithError
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 displaySuccessMessage
55.17% covered (warning)
55.17%
16 / 29
0.00% covered (danger)
0.00%
0 / 1
5.44
 collectParameters
69.49% covered (warning)
69.49%
41 / 59
0.00% covered (danger)
0.00%
0 / 1
55.56
 processParameters
47.06% covered (danger)
47.06%
8 / 17
0.00% covered (danger)
0.00%
0 / 1
17.50
 execute
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * Console command: VuFind installer.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2020.
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  Console
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/wiki/development Wiki
28 */
29
30namespace VuFindConsole\Command\Install;
31
32use Symfony\Component\Console\Attribute\AsCommand;
33use Symfony\Component\Console\Command\Command;
34use Symfony\Component\Console\Input\InputInterface;
35use Symfony\Component\Console\Input\InputOption;
36use Symfony\Component\Console\Output\OutputInterface;
37use Symfony\Component\Console\Question\Question;
38
39use function in_array;
40use function intval;
41
42/**
43 * Console command: VuFind installer.
44 *
45 * @category VuFind
46 * @package  Console
47 * @author   Demian Katz <demian.katz@villanova.edu>
48 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
49 * @link     https://vufind.org/wiki/development Wiki
50 */
51#[AsCommand(
52    name: 'install/install',
53    description: 'VuFind installer'
54)]
55class InstallCommand extends Command
56{
57    public const MULTISITE_NONE = 0;
58    public const MULTISITE_DIR_BASED = 1;
59    public const MULTISITE_HOST_BASED = 2;
60
61    /**
62     * Base directory of VuFind installation.
63     *
64     * @var string
65     */
66    protected $baseDir;
67
68    /**
69     * Local settings directory for VuFind installation.
70     *
71     * @var string
72     */
73    protected $overrideDir;
74
75    /**
76     * Hostname of VuFind installation (used for host-based multi-site).
77     *
78     * @var string
79     */
80    protected $host = '';
81
82    /**
83     * Custom local code module name (if any).
84     *
85     * @var string
86     */
87    protected $module = '';
88
89    /**
90     * Active multi-site mode.
91     *
92     * @var int
93     */
94    protected $multisiteMode = self::MULTISITE_NONE;
95
96    /**
97     * Base path for VuFind URLs.
98     *
99     * @var string
100     */
101    protected $basePath = '/vufind';
102
103    /**
104     * Solr port to use.
105     *
106     * @var string
107     */
108    protected $solrPort = '8983';
109
110    /**
111     * Should we make backups of existing files?
112     *
113     * @var bool
114     */
115    protected $makeBackups = true;
116
117    /**
118     * Constructor
119     *
120     * @param string|null $name The name of the command; passing null means it must
121     * be set in configure()
122     */
123    public function __construct($name = null)
124    {
125        $this->baseDir = str_replace(
126            '\\',
127            '/',
128            realpath(__DIR__ . '/../../../../../../')
129        );
130        $this->overrideDir = $this->baseDir . '/local';
131        parent::__construct($name);
132    }
133
134    /**
135     * Configure the command.
136     *
137     * @return void
138     */
139    protected function configure()
140    {
141        $this
142            ->setHelp('Set up (or modify) initial VuFind installation.')
143            ->addOption(
144                'use-defaults',
145                null,
146                InputOption::VALUE_NONE,
147                'Use VuFind defaults to configure '
148                . '(ignores any other arguments passed)'
149            )->addOption(
150                'overridedir',
151                null,
152                InputOption::VALUE_REQUIRED,
153                'Where would you like to store your local settings?'
154                . " (defaults to {$this->overrideDir} when --non-interactive is set)"
155            )->addOption(
156                'module-name',
157                null,
158                InputOption::VALUE_REQUIRED,
159                'What module name would you like to use? Specify "disabled" to skip'
160            )->addOption(
161                'basepath',
162                null,
163                InputOption::VALUE_REQUIRED,
164                'What base path should be used in VuFind\'s URL?'
165                . " (defaults to {$this->baseDir} when --non-interactive is set)"
166            )->addOption(
167                'multisite',
168                null,
169                InputOption::VALUE_OPTIONAL,
170                'Specify we are going to setup a multisite. '
171                . 'Options: directory and host',
172                false
173            )->addOption(
174                'hostname',
175                null,
176                InputOption::VALUE_REQUIRED,
177                'Specify the hostname for the VuFind Site, when multisite=host'
178            )->addOption(
179                'solr-port',
180                null,
181                InputOption::VALUE_OPTIONAL,
182                'Port number to use for Solr'
183                . " (defaults to {$this->solrPort} when --non-interactive is set)"
184            )->addOption(
185                'non-interactive',
186                null,
187                InputOption::VALUE_NONE,
188                'Use settings if provided via arguments, otherwise use defaults'
189            )->addOption(
190                'skip-backups',
191                null,
192                InputOption::VALUE_NONE,
193                'Overwrite existing files without creating backups'
194            );
195    }
196
197    /**
198     * Write file contents to disk.
199     *
200     * @param string $filename Filename
201     * @param string $content  Content
202     *
203     * @return bool
204     */
205    protected function writeFileToDisk($filename, $content)
206    {
207        return @file_put_contents($filename, $content);
208    }
209
210    /**
211     * Get instructions for editing the Apache configuration under Windows.
212     *
213     * @return string
214     */
215    protected function getWindowsApacheMessage()
216    {
217        return "Go to Start -> Apache HTTP Server -> Edit the Apache httpd.conf\n"
218            . "and add this line to your httpd.conf file: \n"
219            . "     Include {$this->overrideDir}/httpd-vufind.conf\n\n"
220            . "If you are using a bundle like XAMPP and do not have this start\n"
221            . "menu option, you should find and edit your httpd.conf file manually\n"
222            . "(usually in a location like c:\\xampp\\apache\\conf).\n";
223    }
224
225    /**
226     * Get instructions for editing the Apache configuration under Linux.
227     *
228     * @return string
229     */
230    protected function getLinuxApacheMessage()
231    {
232        if (is_dir('/etc/httpd/conf.d')) {                      // Mandriva / RedHat
233            $confD = '/etc/httpd/conf.d';
234            $httpdConf = '/etc/httpd/conf/httpd.conf';
235        } elseif (is_dir('/etc/apache2/2.2/conf.d')) {         // Solaris
236            $confD = '/etc/apache2/2.2/conf.d';
237            $httpdConf = '/etc/apache2/2.2/httpd.conf';
238        } elseif (is_dir('/etc/apache2/conf-enabled')) {   // new Ubuntu / OpenSUSE
239            $confD = '/etc/apache2/conf-enabled';
240            $httpdConf = '/etc/apache2/apache2.conf';
241        } elseif (is_dir('/etc/apache2/conf.d')) {         // old Ubuntu / OpenSUSE
242            $confD = '/etc/apache2/conf.d';
243            $httpdConf = '/etc/apache2/httpd.conf';
244        } elseif (is_dir('/opt/local/apache2/conf/extra')) {   // Mac with Mac Ports
245            $confD = '/opt/local/apache2/conf/extra';
246            $httpdConf = '/opt/local/apache2/conf/httpd.conf';
247        } else {
248            $confD = '/path/to/apache/conf.d';
249            $httpdConf = false;
250        }
251
252        // Check if httpd.conf really exists before recommending a specific path;
253        // if missing, just use the generic name:
254        $httpdConf = ($httpdConf && file_exists($httpdConf))
255            ? $httpdConf : 'httpd.conf';
256
257        // Suggest a symlink name based on the local directory, so if running in
258        // multisite mode, we don't use the same symlink for multiple instances:
259        $symlink = basename($this->overrideDir);
260        $symlink = ($symlink == 'local') ? 'vufind' : ('vufind-' . $symlink);
261        $symlink .= '.conf';
262
263        return "You can do it in either of two ways:\n\n"
264            . "    a) Add this line to your {$httpdConf} file:\n"
265            . "       Include {$this->overrideDir}/httpd-vufind.conf\n\n"
266            . "    b) Link the configuration to Apache's config directory like this:"
267            . "\n       ln -s {$this->overrideDir}/httpd-vufind.conf "
268            . "{$confD}/{$symlink}\n"
269            . "\nOption b is preferable if your platform supports it,\n"
270            . "but option a is more certain to be supported.\n";
271    }
272
273    /**
274     * Display system-specific information for where configuration files are found
275     * and/or symbolic links should be created.
276     *
277     * @param OutputInterface $output Output object
278     *
279     * @return void
280     */
281    protected function getApacheLocation(OutputInterface $output)
282    {
283        // There is one special case for Windows, and a variety of different
284        // Unix-flavored possibilities that all work similarly.
285        $msg = PHP_OS_FAMILY === 'Windows'
286            ? $this->getWindowsApacheMessage() : $this->getLinuxApacheMessage();
287        $output->writeln($msg);
288    }
289
290    /**
291     * Validate a base path. Returns true on success, message on failure.
292     *
293     * @param string $basePath   String to validate.
294     * @param bool   $allowEmpty Are empty values acceptable?
295     *
296     * @return bool|string
297     */
298    protected function validateBasePath($basePath, $allowEmpty = false)
299    {
300        if ($allowEmpty && empty($basePath)) {
301            return true;
302        }
303        return preg_match('/^\/[\w_-]*$/', $basePath)
304            ? true
305            : 'Error: Base path must start with a slash and contain only'
306                . ' alphanumeric characters, dash or underscore.';
307    }
308
309    /**
310     * Validate a Solr port number. Returns true on success, message on failure.
311     *
312     * @param string $solrPort Port to validate.
313     *
314     * @return bool|string
315     */
316    protected function validateSolrPort($solrPort)
317    {
318        if (is_numeric($solrPort)) {
319            return true;
320        }
321        return 'Solr port must be a number.';
322    }
323
324    /**
325     * Get a base path from the user (or return a default).
326     *
327     * @param InputInterface  $input  Input object
328     * @param OutputInterface $output Output object
329     *
330     * @return string
331     */
332    protected function getBasePath(InputInterface $input, OutputInterface $output)
333    {
334        // Get VuFind base path:
335        while (true) {
336            $basePathInput = $this->getInput(
337                $input,
338                $output,
339                "What base path should be used in VuFind's URL? [{$this->basePath}"
340            );
341            if (empty($basePathInput)) {
342                return $this->basePath;
343            } elseif (($result = $this->validateBasePath($basePathInput)) === true) {
344                return $basePathInput;
345            }
346            $output->writeln($result);
347        }
348    }
349
350    /**
351     * Get a Solr port number from the user (or return a default).
352     *
353     * @param InputInterface  $input  Input object
354     * @param OutputInterface $output Output object
355     *
356     * @return string
357     */
358    protected function getSolrPort(InputInterface $input, OutputInterface $output)
359    {
360        // Get VuFind base path:
361        while (true) {
362            $solrInput = $this->getInput(
363                $input,
364                $output,
365                "What port number should Solr use? [{$this->solrPort}"
366            );
367            if (empty($solrInput)) {
368                return $this->solrPort;
369            } elseif (($result = $this->validateSolrPort($solrInput)) === true) {
370                return $solrInput;
371            }
372            $output->writeln($result);
373        }
374    }
375
376    /**
377     * Initialize the override directory and report success or failure.
378     *
379     * @param string $dir Path to attempt to initialize
380     *
381     * @return bool|string
382     */
383    protected function initializeOverrideDir($dir)
384    {
385        return $this->buildDirs(
386            [
387                $dir,
388                $dir . '/cache',
389                $dir . '/config',
390                $dir . '/harvest',
391                $dir . '/import',
392            ]
393        );
394    }
395
396    /**
397     * Get an override directory from the user (or return a default).
398     *
399     * @param InputInterface  $input  Input object
400     * @param OutputInterface $output Output object
401     *
402     * @return string
403     */
404    protected function getOverrideDir(InputInterface $input, OutputInterface $output)
405    {
406        // Get override directory path:
407        while (true) {
408            $overrideDirInput = $this->getInput(
409                $input,
410                $output,
411                'Where would you like to store your local settings? '
412                . "[{$this->overrideDir}"
413            );
414            if (empty($overrideDirInput)) {
415                return $this->overrideDir;
416            } elseif (!$this->initializeOverrideDir($overrideDirInput)) {
417                $output->writeln(
418                    "Error: Cannot initialize settings in '$overrideDirInput'.\n"
419                );
420            }
421            return str_replace('\\', '/', realpath($overrideDirInput));
422        }
423    }
424
425    /**
426     * Validate a comma-separated list of module names. Returns true on success,
427     * message on failure.
428     *
429     * @param string $modules Module names to validate.
430     *
431     * @return bool|string
432     */
433    protected function validateModules($modules)
434    {
435        foreach (explode(',', $modules) as $module) {
436            $result = $this->validateModule(trim($module));
437            if ($result !== true) {
438                return $result;
439            }
440        }
441        return true;
442    }
443
444    /**
445     * Validate the custom module name. Returns true on success, message on failure.
446     *
447     * @param string $module Module name to validate.
448     *
449     * @return bool|string
450     */
451    protected function validateModule($module)
452    {
453        $regex = '/^[a-zA-Z][0-9a-zA-Z_]*$/';
454        $illegalModules = [
455            'VuFind', 'VuFindAdmin', 'VuFindConsole', 'VuFindDevTools',
456            'VuFindLocalTemplate', 'VuFindSearch', 'VuFindTest', 'VuFindTheme',
457        ];
458        if (in_array($module, $illegalModules)) {
459            return "{$module} is a reserved module name; please try another.";
460        } elseif (empty($module) || preg_match($regex, $module)) {
461            return true;
462        }
463        return "Illegal name: {$module}; please use alphanumeric text.";
464    }
465
466    /**
467     * Get the custom module name from the user (or blank for none).
468     *
469     * @param InputInterface  $input  Input object
470     * @param OutputInterface $output Output object
471     *
472     * @return string
473     */
474    protected function getModule(InputInterface $input, OutputInterface $output)
475    {
476        // Get custom module name:
477        $output->writeln(
478            "\nVuFind supports use of a custom module for storing local code "
479            . "changes.\nIf you do not plan to customize the code, you can "
480            . "skip this step.\nIf you decide to use a custom module, the name "
481            . "you choose will be used for\nthe module's directory name and its "
482            . 'PHP namespace.'
483        );
484        while (true) {
485            $moduleInput = trim(
486                $this->getInput(
487                    $input,
488                    $output,
489                    "\nWhat module name would you like to use? [blank for none] "
490                )
491            );
492            if (($result = $this->validateModules($moduleInput)) === true) {
493                return $moduleInput;
494            }
495            $output->writeln("\n$result");
496        }
497    }
498
499    /**
500     * Get the user's preferred multisite mode.
501     *
502     * @param InputInterface  $input  Input object
503     * @param OutputInterface $output Output object
504     *
505     * @return int
506     */
507    protected function getMultisiteMode(
508        InputInterface $input,
509        OutputInterface $output
510    ) {
511        $output->writeln(
512            "\nWhen running multiple VuFind sites against a single installation, you"
513            . " need\nto decide how to distinguish between instances. Choose an "
514            . "option:\n\n" . self::MULTISITE_DIR_BASED . '.) Directory-based '
515            . "(i.e. http://server/vufind1 vs. http://server/vufind2)\n"
516            . self::MULTISITE_HOST_BASED
517            . '.) Host-based (i.e. http://vufind1.server vs. http://vufind2.server)'
518            . "\n\nor enter " . self::MULTISITE_NONE . ' to disable multisite mode.'
519        );
520        $legal = [
521            self::MULTISITE_NONE,
522            self::MULTISITE_DIR_BASED,
523            self::MULTISITE_HOST_BASED,
524        ];
525        while (true) {
526            $response = $this->getInput(
527                $input,
528                $output,
529                "\nWhich option do you want? "
530            );
531            if (is_numeric($response) && in_array(intval($response), $legal)) {
532                return intval($response);
533            }
534            $output->writeln('Invalid selection.');
535        }
536    }
537
538    /**
539     * Validate the user's hostname input. Returns true on success, message on
540     * failure.
541     *
542     * @param string $host String to check
543     *
544     * @return bool|string
545     */
546    protected function validateHost($host)
547    {
548        // From http://stackoverflow.com/questions/106179/
549        //             regular-expression-to-match-hostname-or-ip-address
550        $valid = "/^(([a-zA-Z]|[a-zA-Z][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*"
551            . "([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/";
552        return preg_match($valid, $host)
553            ? true
554            : 'Invalid hostname.';
555    }
556
557    /**
558     * Get the user's hostname preference.
559     *
560     * @param InputInterface  $input  Input object
561     * @param OutputInterface $output Output object
562     *
563     * @return string
564     */
565    protected function getHost(InputInterface $input, OutputInterface $output)
566    {
567        while (true) {
568            $response = $this->getInput(
569                $input,
570                $output,
571                "\nPlease enter the hostname for your site: "
572            );
573            if (($result = $this->validateHost($response)) === true) {
574                return $response;
575            }
576            $output->writeln($result);
577        }
578    }
579
580    /**
581     * Fetch a single line of input from the user.
582     *
583     * @param InputInterface  $input  Input object
584     * @param OutputInterface $output Output object
585     * @param string          $prompt Prompt to display to the user.
586     *
587     * @return string        User-entered response.
588     */
589    protected function getInput(
590        InputInterface $input,
591        OutputInterface $output,
592        string $prompt
593    ): string {
594        $question = new Question($prompt, '');
595        return $this->getHelper('question')->ask($input, $output, $question);
596    }
597
598    /**
599     * Back up an existing file and inform the user. Return true on success,
600     * error message otherwise.
601     *
602     * @param OutputInterface $output   Output object
603     * @param string          $filename File to back up (if it exists)
604     * @param string          $desc     Description of file (for output message)
605     *
606     * @return bool|string
607     */
608    protected function backUpFile(OutputInterface $output, string $filename, string $desc)
609    {
610        if ($this->makeBackups && file_exists($filename)) {
611            $bak = $filename . '.bak.' . time();
612            if (!copy($filename, $bak)) {
613                return "Problem backing up $filename to $bak";
614            }
615            $output->writeln("Backed up existing $desc to $bak.");
616        }
617        return true;
618    }
619
620    /**
621     * Generate the Apache configuration. Returns true on success, error message
622     * otherwise.
623     *
624     * @param OutputInterface $output Output object
625     *
626     * @return bool|string
627     */
628    protected function buildApacheConfig(OutputInterface $output)
629    {
630        $baseConfig = $this->baseDir . '/config/vufind/httpd-vufind.conf';
631        $config = @file_get_contents($baseConfig);
632        if (empty($config)) {
633            return "Problem reading {$baseConfig}.";
634        }
635        $config = str_replace('/usr/local/vufind/local', '%override-dir%', $config);
636        $config = str_replace('/usr/local/vufind', '%base-dir%', $config);
637        $config = preg_replace('|([^/])\/vufind|', '$1%base-path%', $config);
638        $config = str_replace('%override-dir%', $this->overrideDir, $config);
639        $config = str_replace('%base-dir%', $this->baseDir, $config);
640        $config = str_replace('%base-path%', $this->basePath, $config);
641        // Special cases for root basePath:
642        if ('/' == $this->basePath) {
643            $config = str_replace('//', '/', $config);
644            $config = str_replace('Alias /', '#Alias /', $config);
645        }
646        if (!empty($this->module)) {
647            $config = str_replace(
648                '#SetEnv VUFIND_LOCAL_MODULES VuFindLocalTemplate',
649                "SetEnv VUFIND_LOCAL_MODULES {$this->module}",
650                $config
651            );
652        }
653
654        // In multisite mode, we need to make environment variables conditional:
655        switch ($this->multisiteMode) {
656            case self::MULTISITE_DIR_BASED:
657                $config = preg_replace(
658                    '/SetEnv\s+(\w+)\s+(.*)/',
659                    'SetEnvIf Request_URI "^' . $this->basePath . '" $1=$2',
660                    $config
661                );
662                break;
663            case self::MULTISITE_HOST_BASED:
664                if (($result = $this->validateHost($this->host)) !== true) {
665                    return $result;
666                }
667                $config = preg_replace(
668                    '/SetEnv\s+(\w+)\s+(.*)/',
669                    'SetEnvIfNoCase Host ' . str_replace('.', '\.', $this->host)
670                    . ' $1=$2',
671                    $config
672                );
673                break;
674        }
675
676        $target = $this->overrideDir . '/httpd-vufind.conf';
677        if (($msg = $this->backUpFile($output, $target, 'Apache configuration')) !== true) {
678            return $msg;
679        }
680        return $this->writeFileToDisk($target, $config)
681            ? true : "Problem writing {$this->overrideDir}/httpd-vufind.conf.";
682    }
683
684    /**
685     * Get an array of environment variables.
686     *
687     * @return array
688     */
689    protected function getEnvironmentVariables(): array
690    {
691        $vars = [
692            'VUFIND_HOME' => $this->baseDir,
693            'VUFIND_LOCAL_DIR' => $this->overrideDir,
694            'VUFIND_LOCAL_MODULES' => $this->module,
695            'SOLR_PORT' => $this->solrPort,
696        ];
697        if (empty($vars['VUFIND_LOCAL_MODULES'])) {
698            unset($vars['VUFIND_LOCAL_MODULES']);
699        }
700        return $vars;
701    }
702
703    /**
704     * Build the Unix-specific environment configuration. Returns true on success,
705     * error message otherwise.
706     *
707     * @param OutputInterface $output Output object
708     *
709     * @return bool|string
710     */
711    protected function buildUnixEnvironment($output)
712    {
713        $filename = $this->baseDir . '/env.sh';
714        if (($msg = $this->backUpFile($output, $filename, 'Unix environment file')) !== true) {
715            return $msg;
716        }
717        $env = '';
718        foreach ($this->getEnvironmentVariables() as $key => $val) {
719            $env .= "export $key=$val\n";
720        }
721        return $this->writeFileToDisk($filename, $env)
722            ? true : "Problem writing {$filename}.";
723    }
724
725    /**
726     * Build the Windows-specific startup configuration. Returns true on success,
727     * error message otherwise.
728     *
729     * @param OutputInterface $output Output object
730     *
731     * @return bool|string
732     */
733    protected function buildWindowsConfig($output)
734    {
735        $filename = $this->baseDir . '/env.bat';
736        if (($msg = $this->backUpFile($output, $filename, 'Windows environment file')) !== true) {
737            return $msg;
738        }
739        $batch = '';
740        foreach ($this->getEnvironmentVariables() as $key => $val) {
741            $batch .= "@set $key=$val\n";
742        }
743        return $this->writeFileToDisk($filename, $batch)
744            ? true : "Problem writing {$filename}.";
745    }
746
747    /**
748     * Configure a SolrMarc properties file. Returns true on success, error message
749     * otherwise.
750     *
751     * @param OutputInterface $output   Output object
752     * @param string          $filename The properties file to configure
753     *
754     * @return bool|string
755     */
756    protected function buildImportConfig(OutputInterface $output, $filename)
757    {
758        $target = $this->overrideDir . '/import/' . $filename;
759        if (($msg = $this->backUpFile($output, $target, 'import configuration')) !== true) {
760            return $msg;
761        }
762        $import = @file_get_contents($this->baseDir . '/import/' . $filename);
763        $import = str_replace(
764            ['/usr/local/vufind', ':8983'],
765            [$this->baseDir, ':' . $this->solrPort],
766            $import
767        );
768        $import = preg_replace(
769            "/^\s*solrmarc.path\s*=.*$/m",
770            "solrmarc.path = {$this->overrideDir}/import|{$this->baseDir}/import",
771            $import
772        );
773        if (!$this->writeFileToDisk($target, $import)) {
774            return "Problem writing {$this->overrideDir}/import/{$filename}.";
775        }
776        return true;
777    }
778
779    /**
780     * Build a set of directories.
781     *
782     * @param array $dirs Directories to build
783     *
784     * @return bool|string True on success, name of problem directory on failure
785     */
786    protected function buildDirs($dirs)
787    {
788        foreach ($dirs as $dir) {
789            if (!is_dir($dir) && !@mkdir($dir)) {
790                return $dir;
791            }
792        }
793        return true;
794    }
795
796    /**
797     * Make sure all modules exist (and create them if they do not). Returns true
798     * on success, error message otherwise.
799     *
800     * @return bool|string
801     */
802    protected function buildModules()
803    {
804        if (!empty($this->module)) {
805            foreach (explode(',', $this->module) as $module) {
806                $moduleDir = $this->baseDir . '/module/' . $module;
807                // Is module missing? If so, create it from the template:
808                if (!file_exists($moduleDir . '/Module.php')) {
809                    if (($result = $this->buildModule($module)) !== true) {
810                        return $result;
811                    }
812                }
813            }
814        }
815        return true;
816    }
817
818    /**
819     * Build the module for storing local code changes. Returns true on success,
820     * error message otherwise.
821     *
822     * @param string $module The name of the new module (assumed valid!)
823     *
824     * @return bool|string
825     */
826    protected function buildModule($module)
827    {
828        // Create directories:
829        $moduleDir = $this->baseDir . '/module/' . $module;
830        $dirStatus = $this->buildDirs(
831            [
832                $moduleDir,
833                $moduleDir . '/config',
834                $moduleDir . '/src',
835                $moduleDir . '/src/' . $module,
836            ]
837        );
838        if ($dirStatus !== true) {
839            return "Problem creating {$dirStatus}.";
840        }
841
842        // Copy configuration:
843        $configFile = $this->baseDir
844            . '/module/VuFindLocalTemplate/config/module.config.php';
845        $config = @file_get_contents($configFile);
846        if (!$config) {
847            return "Problem reading {$configFile}.";
848        }
849        $success = $this->writeFileToDisk(
850            $moduleDir . '/config/module.config.php',
851            str_replace('VuFindLocalTemplate', $module, $config)
852        );
853        if (!$success) {
854            return "Problem writing {$moduleDir}/config/module.config.php.";
855        }
856
857        // Copy PHP code:
858        $moduleFile = $this->baseDir . '/module/VuFindLocalTemplate/Module.php';
859        $contents = @file_get_contents($moduleFile);
860        if (!$contents) {
861            return "Problem reading {$moduleFile}.";
862        }
863        $success = $this->writeFileToDisk(
864            $moduleDir . '/Module.php',
865            str_replace('VuFindLocalTemplate', $module, $contents)
866        );
867        return $success ? true : "Problem writing {$moduleDir}/Module.php.";
868    }
869
870    /**
871     * Display an error message and return a failure status.
872     *
873     * @param OutputInterface $output Output object
874     * @param string          $msg    Error message
875     * @param int             $status Error status
876     *
877     * @return int
878     */
879    protected function failWithError(
880        OutputInterface $output,
881        string $msg,
882        int $status = 1
883    ): int {
884        $output->writeln($msg);
885        return $status;
886    }
887
888    /**
889     * Display the final message after successful installation.
890     *
891     * @param OutputInterface $output Output object
892     *
893     * @return void
894     */
895    protected function displaySuccessMessage(OutputInterface $output)
896    {
897        $output->writeln(
898            "Apache configuration written to {$this->overrideDir}/httpd-vufind.conf."
899            . "\n\nYou now need to load this configuration into Apache."
900        );
901        $this->getApacheLocation($output);
902        if (!empty($this->host)) {
903            $output->writeln(
904                'Since you are using a host-based multisite configuration, you will '
905                . "also \nneed to do some virtual host configuration. See\n"
906                . "     http://httpd.apache.org/docs/2.4/vhosts/\n"
907            );
908        }
909        if ('/' == $this->basePath) {
910            $output->writeln(
911                'Since you are installing VuFind at the root of your domain, you '
912                . "will also\nneed to edit your Apache configuration to change "
913                . "DocumentRoot to:\n" . $this->baseDir . "/public\n"
914            );
915        }
916        $output->writeln(
917            'Once the configuration is linked, restart Apache. You should now be '
918            . "able\nto access VuFind at http://localhost{$this->basePath}\n\nFor "
919            . "proper use of command line tools, you should also ensure that your\n"
920        );
921        $finalMsg = empty($this->addOptionmodule)
922            ? "VUFIND_HOME and VUFIND_LOCAL_DIR environment variables are set to\n"
923            . "{$this->baseDir} and {$this->overrideDir} respectively."
924            : "VUFIND_HOME, VUFIND_LOCAL_MODULES and VUFIND_LOCAL_DIR environment\n"
925            . "variables are set to {$this->baseDir}{$this->module} and "
926            . "{$this->overrideDir} respectively.";
927        $output->writeln($finalMsg);
928    }
929
930    /**
931     * Collect input parameters, and return a status (0 = proceed, 1 = fail).
932     *
933     * @param InputInterface  $input  Input object
934     * @param OutputInterface $output Output object
935     *
936     * @return int 0 for success
937     */
938    protected function collectParameters(
939        InputInterface $input,
940        OutputInterface $output
941    ) {
942        // Are we allowing user interaction?
943        $interactive = !$input->getOption('non-interactive');
944        $userInputNeeded = [];
945
946        // Load user settings if we are not forcing defaults:
947        if (!$input->getOption('use-defaults')) {
948            $overrideDir = trim($input->getOption('overridedir') ?? '');
949            if (!empty($overrideDir)) {
950                $this->overrideDir = $overrideDir;
951            } elseif ($interactive) {
952                $userInputNeeded['overrideDir'] = true;
953            }
954            $moduleName = trim($input->getOption('module-name') ?? '');
955            if (!empty($moduleName) && $moduleName !== 'disabled') {
956                if (($result = $this->validateModules($moduleName)) !== true) {
957                    return $this->failWithError($output, $result);
958                }
959                $this->module = $moduleName;
960            } elseif ($interactive) {
961                $userInputNeeded['module'] = true;
962            }
963
964            $basePath = trim($input->getOption('basepath') ?? '');
965            if (!empty($basePath)) {
966                if (($result = $this->validateBasePath($basePath, true)) !== true) {
967                    return $this->failWithError($output, $result);
968                }
969                $this->basePath = $basePath;
970            } elseif ($interactive) {
971                $userInputNeeded['basePath'] = true;
972            }
973
974            $solrPort = trim($input->getOption('solr-port') ?? '');
975            if (!empty($solrPort)) {
976                if (($result = $this->validateSolrPort($solrPort)) !== true) {
977                    return $this->failWithError($output, $result);
978                }
979                $this->solrPort = $solrPort;
980            } elseif ($interactive) {
981                $userInputNeeded['solr-port'] = true;
982            }
983
984            // We assume "single site" mode unless the --multisite option is set;
985            // note that $mode will be null if the user provided the option with
986            // no value specified, and false if the user did not provide the option.
987            $mode = $input->getOption('multisite');
988            if ($mode === 'directory') {
989                $this->multisiteMode = self::MULTISITE_DIR_BASED;
990            } elseif ($mode === 'host') {
991                $this->multisiteMode = self::MULTISITE_HOST_BASED;
992            } elseif ($mode !== true && $mode !== null && $mode !== false) {
993                return $this->failWithError(
994                    $output,
995                    'Unexpected multisite mode: ' . $mode
996                );
997            } elseif ($interactive && $mode !== false) {
998                $userInputNeeded['multisiteMode'] = true;
999            }
1000
1001            // Now that we've validated as many parameters as possible, retrieve
1002            // user input where needed.
1003            if (isset($userInputNeeded['overrideDir'])) {
1004                $this->overrideDir = $this->getOverrideDir($input, $output);
1005            }
1006            if (isset($userInputNeeded['module'])) {
1007                $this->module = $this->getModule($input, $output);
1008            }
1009            if (isset($userInputNeeded['basePath'])) {
1010                $this->basePath = $this->getBasePath($input, $output);
1011            }
1012            if (isset($userInputNeeded['solr-port'])) {
1013                $this->solrPort = $this->getSolrPort($input, $output);
1014            }
1015            if (isset($userInputNeeded['multisiteMode'])) {
1016                $this->multisiteMode = $this->getMultisiteMode($input, $output);
1017            }
1018
1019            // Load supplemental multisite parameters:
1020            if ($this->multisiteMode == self::MULTISITE_HOST_BASED) {
1021                $hostOption = trim($input->getOption('hostname'));
1022                $this->host = (!empty($hostOption) || !$interactive)
1023                    ? $hostOption : $this->getHost($input, $output);
1024            }
1025        }
1026
1027        // Normalize the module setting to remove whitespace:
1028        $this->module = preg_replace('/\s/', '', $this->module);
1029
1030        // Should we make backups of existing files?
1031        if ($input->getOption('skip-backups')) {
1032            $this->makeBackups = false;
1033        }
1034        return 0;
1035    }
1036
1037    /**
1038     * Process collected parameters, and return a status (0 = proceed, 1 = fail).
1039     *
1040     * @param OutputInterface $output Output object
1041     *
1042     * @return int 0 for success
1043     */
1044    protected function processParameters(OutputInterface $output)
1045    {
1046        // Make sure the override directory is initialized (using defaults or CLI
1047        // parameters will not have initialized it yet; attempt to reinitialize it
1048        // here is harmless if it was already initialized in interactive mode):
1049        if (!$this->initializeOverrideDir($this->overrideDir)) {
1050            return $this->failWithError(
1051                $output,
1052                "Cannot initialize local override directory: {$this->overrideDir}"
1053            );
1054        }
1055
1056        // Build the Windows start file in case we need it:
1057        if (($result = $this->buildWindowsConfig($output)) !== true) {
1058            return $this->failWithError($output, $result);
1059        }
1060
1061        // Build a Unix environment file in case we need it:
1062        if (($result = $this->buildUnixEnvironment($output)) !== true) {
1063            return $this->failWithError($output, $result);
1064        }
1065
1066        // Build the import configuration:
1067        foreach (['import.properties', 'import_auth.properties'] as $file) {
1068            if (($result = $this->buildImportConfig($output, $file)) !== true) {
1069                return $this->failWithError($output, $result);
1070            }
1071        }
1072
1073        // Build the custom module(s), if necessary:
1074        if (($result = $this->buildModules()) !== true) {
1075            return $this->failWithError($output, $result);
1076        }
1077
1078        // Build the final configuration:
1079        if (($result = $this->buildApacheConfig($output)) !== true) {
1080            return $this->failWithError($output, $result);
1081        }
1082        return 0;
1083    }
1084
1085    /**
1086     * Run the command.
1087     *
1088     * @param InputInterface  $input  Input object
1089     * @param OutputInterface $output Output object
1090     *
1091     * @return int 0 for success
1092     */
1093    protected function execute(InputInterface $input, OutputInterface $output)
1094    {
1095        $output->writeln("VuFind has been found in {$this->baseDir}.");
1096
1097        // Collect and process parameters, and stop if an error is encountered
1098        // along the way....
1099        if (
1100            $this->collectParameters($input, $output) !== 0
1101            || $this->processParameters($output) !== 0
1102        ) {
1103            return 1;
1104        }
1105
1106        // Report success:
1107        $this->displaySuccessMessage($output);
1108        return 0;
1109    }
1110}