1<?php
2/*
3 * vim:set softtabstop=4 shiftwidth=4 expandtab:
4 *
5 * LICENSE: GNU Affero General Public License, version 3 (AGPL-3.0-or-later)
6 * Copyright 2001 - 2020 Ampache.org
7 *
8 * This program is free software: you can redistribute it and/or modify
9 * it under the terms of the GNU Affero General Public License as published by
10 * the Free Software Foundation, either version 3 of the License, or
11 * (at your option) any later version.
12 *
13 * This program is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 * GNU Affero General Public License for more details.
17 *
18 * You should have received a copy of the GNU Affero General Public License
19 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
20 *
21 */
22
23declare(strict_types=0);
24
25namespace Ampache\Module\System;
26
27use Ampache\Config\AmpConfig;
28use Ampache\Repository\Model\Preference;
29use Ampache\Repository\Model\User;
30use Ampache\Module\Util\Horde_Browser;
31use Exception;
32
33final class InstallationHelper implements InstallationHelperInterface
34{
35
36    /**
37     * splits up a standard SQL dump file into distinct sql queries
38     * @param string $sql
39     * @return array
40     */
41    private function split_sql($sql): array
42    {
43        $sql       = trim((string) $sql);
44        $sql       = preg_replace("/\n#[^\n]*\n/", "\n", $sql);
45        $buffer    = array();
46        $ret       = array();
47        $in_string = false;
48        for ($count = 0; $count < strlen((string) $sql) - 1; $count++) {
49            if ($sql[$count] == ";" && !$in_string) {
50                $ret[] = substr($sql, 0, $count);
51                $sql   = substr($sql, $count + 1);
52                $count = 0;
53            }
54            if ($in_string && ($sql[$count] == $in_string) && $buffer[1] != "\\") {
55                $in_string = false;
56            } elseif (!$in_string && ($sql[$count] == '"' || $sql[$count] == "'") && (!isset($buffer[0]) || $buffer[0] != "\\")) {
57                $in_string = $sql[$count];
58            }
59            if (isset($buffer[1])) {
60                $buffer[0] = $buffer[1];
61            }
62            $buffer[1] = $sql[$count];
63        }
64        if (!empty($sql)) {
65            $ret[] = $sql;
66        }
67
68        return $ret;
69    }
70
71    /**
72     * this function checks to see if we actually
73     * still need to install ampache. This function is
74     * very important, we don't want to reinstall over top of an existing install
75     * @param $configfile
76     * @return boolean
77     */
78    public function install_check_status($configfile)
79    {
80        /**
81         * Check and see if the config file exists
82         * if it does they can't use the web interface
83         * to install ampache.
84         */
85        if (!file_exists($configfile)) {
86            return true;
87        }
88
89        /**
90         * Check and see if they've got _any_ account
91         * if they don't then they're cool
92         */
93        $results = parse_ini_file($configfile);
94        AmpConfig::set_by_array($results, true);
95
96        if (!Dba::check_database()) {
97            AmpError::add('general', T_('Unable to connect to the database, check your Ampache config'));
98
99            return false;
100        }
101
102        $sql        = 'SELECT * FROM `user`';
103        $db_results = Dba::read($sql);
104
105        if (!$db_results) {
106            AmpError::add('general', T_('Unable to query the database, check your Ampache config'));
107
108            return false;
109        }
110
111        if (!Dba::num_rows($db_results)) {
112            return true;
113        } else {
114            AmpError::add('general', T_('Existing database was detected, unable to continue the installation'));
115
116            return false;
117        }
118    } // install_check_status
119
120    /**
121     * @return boolean
122     */
123    public function install_check_server_apache()
124    {
125        return (strpos($_SERVER['SERVER_SOFTWARE'], "Apache/") === 0);
126    }
127
128    /**
129     * @param string $file
130     * @param $web_path
131     * @param boolean $fix
132     * @return boolean|string
133     */
134    public function install_check_rewrite_rules($file, $web_path, $fix = false)
135    {
136        if (!is_readable($file)) {
137            $file .= '.dist';
138        }
139        $valid     = true;
140        $htaccess  = file_get_contents($file);
141        $new_lines = array();
142        $lines     = explode("\n", $htaccess);
143        foreach ($lines as $line) {
144            $parts   = explode(' ', (string) $line);
145            $p_count = count($parts);
146            for ($count = 0; $count < $p_count; $count++) {
147                // Matching url rewriting rule syntax
148                if ($parts[$count] === 'RewriteRule' && $count < ($p_count - 2)) {
149                    $reprule = $parts[$count + 2];
150                    if (!empty($web_path) && strpos($reprule, $web_path) !== 0) {
151                        $reprule = $web_path . $reprule;
152                        if ($fix) {
153                            $parts[$count + 2] = $reprule;
154                            $line              = implode(' ', $parts);
155                        } else {
156                            $valid = false;
157                        }
158                    }
159                    break;
160                }
161            }
162
163            if ($fix) {
164                $new_lines[] = $line;
165            }
166        }
167
168        if ($fix) {
169            return implode("\n", $new_lines);
170        }
171
172        return $valid;
173    }
174
175    /**
176     * @param string $file
177     * @param $web_path
178     * @param boolean $download
179     * @return boolean
180     */
181    public function install_rewrite_rules($file, $web_path, $download)
182    {
183        $final = $this->install_check_rewrite_rules($file, $web_path, true);
184        if (!$download) {
185            if (!file_put_contents($file, $final)) {
186                AmpError::add('general', T_('Failed to write config file'));
187
188                return false;
189            }
190        } else {
191            $browser = new Horde_Browser();
192            $headers = $browser->getDownloadHeaders(basename($file), 'text/plain', false, strlen((string) $final));
193
194            foreach ($headers as $headerName => $value) {
195                header(sprintf('%s: %s', $headerName, $value));
196            }
197            echo $final;
198
199            return false;
200        }
201
202        return true;
203    }
204
205    /**
206     * install_insert_db
207     *
208     * Inserts the database using the values from Config.
209     * @param string $db_user
210     * @param string $db_pass
211     * @param boolean $create_db
212     * @param boolean $overwrite
213     * @param boolean $create_tables
214     * @param string $charset
215     * @param string $collation
216     * @return boolean
217     */
218    public function install_insert_db($db_user = null, $db_pass = null, $create_db = true, $overwrite = false, $create_tables = true, $charset = 'utf8mb4', $collation = 'utf8mb4_unicode_ci_unicode_ci')
219    {
220        $database = (string) AmpConfig::get('database_name');
221        // Make sure that the database name is valid
222        preg_match('/([^\d\w\_\-])/', $database, $matches);
223
224        if (count($matches)) {
225            AmpError::add('general', T_('Database name is invalid'));
226
227            return false;
228        }
229
230        if (!Dba::check_database()) {
231            /* HINT: Database error message */
232            AmpError::add('general', sprintf(T_('Unable to connect to the database: %s'), Dba::error()));
233
234            return false;
235        }
236
237        $db_exists = Dba::read('SHOW TABLES');
238
239        if ($db_exists && $create_db) {
240            if ($overwrite) {
241                Dba::write('DROP DATABASE `' . $database . '`');
242            } else {
243                AmpError::add('general', T_('Database already exists and "overwrite" was not checked'));
244
245                return false;
246            }
247        }
248
249        if ($create_db) {
250            if (!Dba::write('CREATE DATABASE `' . $database . '`')) {
251                /* HINT: Database error message */
252                AmpError::add('general', sprintf(T_('Unable to create the database: %s'), Dba::error()));
253
254                return false;
255            }
256        }
257
258        Dba::disconnect();
259
260        // Check to see if we should create a user here
261        if (strlen((string) $db_user) && strlen((string) $db_pass)) {
262            $db_host  = AmpConfig::get('database_hostname');
263            // create the user account
264            $sql_user = "CREATE USER '" . Dba::escape($db_user) . "'";
265            if ($db_host == 'localhost' || strpos($db_host, '/') === 0) {
266                $sql_user .= "@'localhost'";
267            }
268            $sql_user .= " IDENTIFIED BY '" . Dba::escape($db_pass) . "'";
269            if (!Dba::write($sql_user)) {
270                AmpError::add('general', sprintf(
271                /* HINT: %1 user, %2 database, %3 host, %4 error message */
272                    T_('Unable to create the user "%1$s" with permissions to "%2$s" on "%3$s": %4$s'), $db_user, $database, $db_host, Dba::error()));
273
274                return false;
275            }
276            // grant database access to that account
277            $sql_grant = "GRANT ALL PRIVILEGES ON `" . Dba::escape($database) . "`.* TO '" . Dba::escape($db_user) . "'";
278            if ($db_host == 'localhost' || strpos($db_host, '/') === 0) {
279                $sql_grant .= "@'localhost'";
280            }
281            $sql_grant .= " WITH GRANT OPTION";
282
283            if (!Dba::write($sql_grant)) {
284                AmpError::add('general', sprintf(
285                /* HINT: %1 database, %2 user, %3 host, %4 error message */
286                    T_('Unable to grant permissions to "%1$s" for the user "%2$s" on "%3$s": %4$s'), $database, $db_user, $db_host, Dba::error()));
287
288                return false;
289            }
290        } // end if we are creating a user
291
292        if ($create_tables) {
293            $sql_file = __DIR__ . '/../../../resources/sql/ampache.sql';
294            $query    = fread(fopen($sql_file, 'r'), filesize($sql_file));
295            $pieces   = $this->split_sql($query);
296            $p_count  = count($pieces);
297            $errors   = array();
298            for ($count = 0; $count < $p_count; $count++) {
299                $pieces[$count] = trim((string) $pieces[$count]);
300                if (!empty($pieces[$count]) && $pieces[$count] != '#') {
301                    if (!Dba::write($pieces[$count])) {
302                        $errors[] = array(Dba::error(), $pieces[$count]);
303                    }
304                }
305            }
306        }
307
308        if ($create_db) {
309            $sql = "ALTER DATABASE `" . $database . "` DEFAULT CHARACTER SET $charset COLLATE " . $collation;
310            Dba::write($sql);
311            // if you've set a custom collation we need to change it
312            $tables = array("access_list", "album", "artist", "bookmark", "broadcast", "cache_object_count", "cache_object_count_run", "catalog", "catalog_local", "catalog_remote", "channel", "clip", "daap_session", "democratic", "image", "ip_history", "label", "label_asso", "license", "live_stream", "localplay_httpq", "localplay_mpd", "metadata", "metadata_field", "movie", "now_playing", "object_count", "personal_video", "player_control", "playlist", "playlist_data", "podcast", "podcast_episode", "preference", "rating", "recommendation", "recommendation_item", "search", "session", "session_remember", "session_stream", "share", "song", "song_data", "song_preview", "stream_playlist", "tag", "tag_map", "tag_merge", "tmp_browse", "tmp_playlist", "tmp_playlist_data", "tvshow", "tvshow_episode", "tvshow_season", "update_info", "user", "user_activity", "user_catalog", "user_flag", "user_follower", "user_preference", "user_pvmsg", "user_shout", "user_vote", "video", "wanted");
313            foreach ($tables as $table_name) {
314                $sql = "ALTER TABLE `" . $table_name . "` CHARACTER SET $charset COLLATE " . $collation;
315                Dba::write($sql);
316            }
317        }
318
319        // If they've picked something other than English update default preferences
320        if (AmpConfig::get('lang') != 'en_US') {
321            // FIXME: 31? I hate magic.
322            $sql = 'UPDATE `preference` SET `value`= ? WHERE `id` = 31';
323            Dba::write($sql, array(AmpConfig::get('lang')));
324            $sql = 'UPDATE `user_preference` SET `value` = ? WHERE `preference` = 31';
325            Dba::write($sql, array(AmpConfig::get('lang')));
326        }
327
328        return true;
329    }
330
331    /**
332     * Attempts to write out the config file or offer it as a download.
333     * @param boolean $download
334     * @return boolean
335     * @throws Exception
336     */
337    public function install_create_config($download = false)
338    {
339        $config_file = __DIR__ . '/../../../config/ampache.cfg.php';
340
341        /* Attempt to make DB connection */
342        Dba::dbh();
343
344        $params = AmpConfig::get_all();
345        if (empty($params['database_username']) || (empty($params['database_password']) && strpos($params['database_hostname'], '/') !== 0)) {
346            AmpError::add('general', T_("Invalid configuration settings"));
347
348            return false;
349        }
350
351        // Connect to the DB
352        if (!Dba::check_database()) {
353            AmpError::add('general', T_("Connection to the database failed: Check hostname, username and password"));
354
355            return false;
356        }
357
358        $final = $this->generate_config($params);
359
360        // Make sure the directory is writable OR the empty config file is
361        if (!$download) {
362            if (!check_config_writable()) {
363                AmpError::add('general', T_('Config file is not writable'));
364
365                return false;
366            } else {
367                // Given that $final is > 0, we can ignore lazy comparison problems
368                if (!file_put_contents($config_file, $final)) {
369                    AmpError::add('general', T_('Failed writing config file'));
370
371                    return false;
372                }
373            }
374        } else {
375            $browser = new Horde_Browser();
376            $headers = $browser->getDownloadHeaders('ampache.cfg.php', 'text/plain', false, strlen((string) $final));
377            foreach ($headers as $headerName => $value) {
378                header(sprintf('%s: %s', $headerName, $value));
379            }
380            echo $final;
381
382            return false;
383        }
384
385        return true;
386    }
387
388    /**
389     * this creates your initial account and sets up the preferences for the -1 user and you
390     * @param string $username
391     * @param string $password
392     * @param string $password2
393     * @return boolean
394     */
395    public function install_create_account($username, $password, $password2)
396    {
397        if (!strlen((string) $username) || !strlen((string) $password)) {
398            AmpError::add('general', T_('No username or password was specified'));
399
400            return false;
401        }
402
403        if ($password !== $password2) {
404            AmpError::add('general', T_('Passwords do not match'));
405
406            return false;
407        }
408
409        if (!Dba::check_database()) {
410            /* HINT: Database error message */
411            AmpError::add('general', sprintf(T_('Connection to the database failed: %s'), Dba::error()));
412
413            return false;
414        }
415
416        if (!Dba::check_database_inserted()) {
417            /* HINT: Database error message */
418            AmpError::add('general', sprintf(T_('Database select failed: %s'), Dba::error()));
419
420            return false;
421        }
422
423        $username = Dba::escape($username);
424        $password = Dba::escape($password);
425
426        $user_id = User::create($username, 'Administrator', '', '', $password, '100');
427
428        if ($user_id < 1) {
429            /* HINT: Database error message */
430            AmpError::add('general', sprintf(T_('Administrative user creation failed: %s'), Dba::error()));
431
432            return false;
433        }
434
435        // Fix the system users preferences
436        User::fix_preferences('-1');
437
438        return true;
439    } // install_create_account
440
441    /**
442     * @param string $command
443     * @return boolean
444     */
445    private function command_exists($command)
446    {
447        if (!function_exists('proc_open')) {
448            return false;
449        }
450
451        $whereIsCommand = (PHP_OS == 'WINNT') ? 'where' : 'which';
452        $process        = proc_open(
453            "$whereIsCommand $command",
454            array(
455                0 => array("pipe", "r"), // STDIN
456                1 => array("pipe", "w"), // STDOUT
457                2 => array("pipe", "w"), // STDERR
458            ),
459            $pipes
460        );
461
462        if ($process !== false) {
463            $stdout = stream_get_contents($pipes[1]);
464            stream_get_contents($pipes[2]);
465            fclose($pipes[1]);
466            fclose($pipes[2]);
467            proc_close($process);
468
469            return $stdout != '';
470        }
471
472        return false;
473    }
474
475    /**
476     * get transcode modes available on this machine.
477     * @return array
478     */
479    public function install_get_transcode_modes()
480    {
481        $modes = array();
482
483        if ($this->command_exists('ffmpeg')) {
484            $modes[] = 'ffmpeg';
485        }
486        if ($this->command_exists('avconv')) {
487            $modes[] = 'avconv';
488        }
489
490        return $modes;
491    } // install_get_transcode_modes
492
493    /**
494     * @param $mode
495     */
496    public function install_config_transcode_mode($mode)
497    {
498        $trconfig = array(
499            'encode_target' => 'mp3',
500            'encode_video_target' => 'webm',
501            'transcode_m4a' => 'required',
502            'transcode_flac' => 'required',
503            'transcode_mpc' => 'required',
504            'transcode_ogg' => 'allowed',
505            'transcode_wav' => 'required',
506            'transcode_avi' => 'allowed',
507            'transcode_mpg' => 'allowed',
508            'transcode_mkv' => 'allowed',
509        );
510        if ($mode == 'ffmpeg' || $mode == 'avconv') {
511            $trconfig['transcode_cmd']          = $mode;
512            $trconfig['transcode_input']        = '-i %FILE%';
513            $trconfig['waveform']               = 'true';
514            $trconfig['generate_video_preview'] = 'true';
515
516            AmpConfig::set_by_array($trconfig, true);
517        }
518    }
519
520    /**
521     * @param $case
522     */
523    public function install_config_use_case($case)
524    {
525        $trconfig = array(
526            'use_auth' => 'true',
527            'ratings' => 'true',
528            'userflags' => 'true',
529            'sociable' => 'true',
530            'licensing' => 'false',
531            'wanted' => 'false',
532            'channel' => 'false',
533            'live_stream' => 'true',
534            'allow_public_registration' => 'false',
535            'cookie_disclaimer' => 'false',
536            'share' => 'false'
537        );
538
539        $dbconfig = array(
540            'download' => '1',
541            'share' => '0',
542            'allow_video' => '0',
543            'home_now_playing' => '1',
544            'home_recently_played' => '1'
545        );
546
547        switch ($case) {
548            case 'minimalist':
549                $trconfig['ratings']                   = 'false';
550                $trconfig['userflags']                 = 'false';
551                $trconfig['sociable']                  = 'false';
552                $trconfig['wanted']                    = 'false';
553                $trconfig['channel']                   = 'false';
554                $trconfig['live_stream']               = 'false';
555
556                $dbconfig['download']    = '0';
557                $dbconfig['allow_video'] = '0';
558
559                $cookie_options = [
560                    'expires' => time() + (30 * 24 * 60 * 60),
561                    'path' => '/',
562                    'samesite' => 'Strict'
563                ];
564
565                // Default local UI preferences to have a better 'minimalist first look'.
566                setcookie('sidebar_state', 'collapsed', $cookie_options);
567                setcookie('browse_album_grid_view', 'false', $cookie_options);
568                setcookie('browse_artist_grid_view', 'false', $cookie_options);
569                break;
570            case 'community':
571                $trconfig['use_auth']                                = 'false';
572                $trconfig['licensing']                               = 'true';
573                $trconfig['wanted']                                  = 'false';
574                $trconfig['live_stream']                             = 'false';
575                $trconfig['allow_public_registration']               = 'true';
576                $trconfig['cookie_disclaimer']                       = 'true';
577                $trconfig['share']                                   = 'true';
578
579                $dbconfig['download']             = '0';
580                $dbconfig['share']                = '1';
581                $dbconfig['home_now_playing']     = '0';
582                $dbconfig['home_recently_played'] = '0';
583                break;
584            default:
585                break;
586        }
587
588        AmpConfig::set_by_array($trconfig, true);
589        foreach ($dbconfig as $preference => $value) {
590            Preference::update($preference, -1, $value, true, true);
591        }
592    }
593
594    /**
595     * @param array $backends
596     */
597    public function install_config_backends(array $backends)
598    {
599        $dbconfig = array(
600            'subsonic_backend' => '0',
601            'daap_backend' => '0',
602            'upnp_backend' => '0',
603            'webdav_backend' => '0',
604            'stream_beautiful_url' => '0'
605        );
606
607        foreach ($backends as $backend) {
608            switch ($backend) {
609                case 'subsonic':
610                    $dbconfig['subsonic_backend'] = '1';
611                    break;
612                case 'upnp':
613                    $dbconfig['upnp_backend']         = '1';
614                    $dbconfig['stream_beautiful_url'] = '1';
615                    break;
616                case 'daap':
617                    $dbconfig['daap_backend'] = '1';
618                    break;
619                case 'webdav':
620                    $dbconfig['webdav_backend'] = '1';
621                    break;
622            }
623        }
624
625        foreach ($dbconfig as $preference => $value) {
626            Preference::update($preference, -1, $value, true, true);
627        }
628    }
629
630    /**
631     * Write new configuration into the current configuration file by keeping old values.
632     * @param string $current_file_path
633     * @throws Exception
634     */
635    public function write_config(string $current_file_path): void
636    {
637        $new_data = $this->generate_config(parse_ini_file($current_file_path));
638
639        // Start writing into the current config file
640        $handle = fopen($current_file_path, 'w+');
641        fwrite($handle, $new_data, strlen((string) $new_data));
642        fclose($handle);
643    }
644
645    /**
646     * This takes an array of results and re-generates the config file
647     * this is used by the installer and by the admin/system page
648     * @param array $current
649     * @return string
650     * @throws Exception
651     */
652    public function generate_config(array $current): string
653    {
654        // Start building the new config file
655        $distfile = __DIR__ . '/../../../config/ampache.cfg.php.dist';
656        $handle   = fopen($distfile, 'r');
657        $dist     = fread($handle, filesize($distfile));
658        fclose($handle);
659
660        $data  = explode("\n", (string) $dist);
661        $final = "";
662        foreach ($data as $line) {
663            if (preg_match("/^;?([\w\d]+)\s+=\s+[\"]{1}(.*?)[\"]{1}$/", $line, $matches)
664                || preg_match("/^;?([\w\d]+)\s+=\s+[\']{1}(.*?)[\']{1}$/", $line, $matches)
665                || preg_match("/^;?([\w\d]+)\s+=\s+[\'\"]{0}(.*)[\'\"]{0}$/", $line, $matches)
666                || preg_match("/^;?([\w\d]+)\s{0}=\s{0}[\'\"]?(.*?)[\'\"]?$/", $line, $matches)) {
667                $key   = $matches[1];
668                $value = $matches[2];
669
670                // Put in the current value
671                if ($key == 'config_version') {
672                    $line = $key . ' = ' . $this->escape_ini($value);
673                } elseif ($key == 'secret_key' && !isset($current[$key])) {
674                    $secret_key = Core::gen_secure_token(31);
675                    if ($secret_key !== false) {
676                        $line = $key . ' = "' . $this->escape_ini($secret_key) . '"';
677                    }
678                    // Else, unable to generate a cryptographically secure token, use the default one
679                } elseif (isset($current[$key])) {
680                    $line = $key . ' = "' . $this->escape_ini((string) $current[$key]) . '"';
681                    unset($current[$key]);
682                }
683            }
684
685            $final .= $line . "\n";
686        }
687
688        return $final;
689    }
690
691    /**
692     * Escape a value used for inserting into an ini file.
693     * Won't quote ', like addslashes does.
694     * @param string|string[] $str
695     * @return string|string[]
696     */
697    private function escape_ini($str)
698    {
699        return str_replace('"', '\"', $str);
700    }
701}
702