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