src/Module/System/InstallationHelper.php
<?php
declare(strict_types=0);
/**
* vim:set softtabstop=4 shiftwidth=4 expandtab:
*
* LICENSE: GNU Affero General Public License, version 3 (AGPL-3.0-or-later)
* Copyright Ampache.org, 2001-2023
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
namespace Ampache\Module\System;
use Ampache\Config\AmpConfig;
use Ampache\Repository\Model\Preference;
use Ampache\Repository\Model\User;
use Ampache\Module\Util\Horde_Browser;
use Exception;
final class InstallationHelper implements InstallationHelperInterface
{
/**
* splits up a standard SQL dump file into distinct sql queries
* @param string $sql
*/
private function split_sql($sql): array
{
$sql = trim((string) $sql);
$sql = preg_replace("/\n--[^\n]*\n/", "\n", $sql);
$buffer = array();
$ret = array();
$in_string = false;
for ($count = 0; $count < strlen((string) $sql) - 1; $count++) {
if ($sql[$count] == ";" && !$in_string) {
$ret[] = substr($sql, 0, $count);
$sql = substr($sql, $count + 1);
$count = 0;
}
if ($in_string && ($sql[$count] == $in_string) && $buffer[1] != "\\") {
$in_string = false;
} elseif (!$in_string && ($sql[$count] == '"' || $sql[$count] == "'") && (!isset($buffer[0]) || $buffer[0] != "\\")) {
$in_string = $sql[$count];
}
if (isset($buffer[1])) {
$buffer[0] = $buffer[1];
}
$buffer[1] = $sql[$count];
}
if (!empty($sql)) {
$ret[] = $sql;
}
return $ret;
}
/**
* this function checks to see if we actually
* still need to install ampache. This function is
* very important, we don't want to reinstall over top of an existing install
* @param $configfile
*/
public function install_check_status($configfile): bool
{
/**
* Check and see if the config file exists
* if it does they can't use the web interface
* to install ampache.
*/
if (!file_exists($configfile)) {
return true;
}
/**
* Check and see if they've got _any_ account
* if they don't then they're cool
*/
$results = parse_ini_file($configfile);
AmpConfig::set_by_array($results, true);
if (!Dba::check_database()) {
AmpError::add('general', T_('Unable to connect to the database, check your Ampache config'));
return false;
}
$sql = 'SELECT * FROM `user`';
$db_results = Dba::read($sql);
if (!$db_results) {
AmpError::add('general', T_('Unable to query the database, check your Ampache config'));
return false;
}
if (!Dba::num_rows($db_results)) {
return true;
} else {
AmpError::add('general', T_('Existing database was detected, unable to continue the installation'));
return false;
}
}
/**
* install_check_server_apache
*/
public function install_check_server_apache(): bool
{
return (strpos($_SERVER['SERVER_SOFTWARE'], "Apache/") === 0);
}
/**
* @param string $file
* @param $web_path
* @param bool $fix
* @return bool|string
*/
public function install_check_rewrite_rules($file, $web_path, $fix = false)
{
if (!is_readable($file)) {
$file .= '.dist';
}
$valid = true;
$htaccess = file_get_contents($file);
$new_lines = array();
$lines = explode("\n", $htaccess);
foreach ($lines as $line) {
$parts = explode(' ', (string) $line);
$p_count = count($parts);
for ($count = 0; $count < $p_count; $count++) {
// Matching url rewriting rule syntax
if ($parts[$count] === 'RewriteRule' && $count < ($p_count - 2)) {
$reprule = $parts[$count + 2];
if (!empty($web_path) && strpos($reprule, $web_path) !== 0) {
$reprule = $web_path . $reprule;
if ($fix) {
$parts[$count + 2] = $reprule;
$line = implode(' ', $parts);
} else {
$valid = false;
}
}
break;
}
}
if ($fix) {
$new_lines[] = $line;
}
}
if ($fix) {
return implode("\n", $new_lines);
}
return $valid;
}
/**
* @param string $file
* @param $web_path
* @param bool $download
*/
public function install_rewrite_rules($file, $web_path, $download): bool
{
$final = $this->install_check_rewrite_rules($file, $web_path, true);
if (!$download) {
if (!file_put_contents($file, $final)) {
AmpError::add('general', T_('Failed to write config file'));
return false;
}
} else {
$browser = new Horde_Browser();
$headers = $browser->getDownloadHeaders(basename($file), 'text/plain', false, strlen((string) $final));
foreach ($headers as $headerName => $value) {
header(sprintf('%s: %s', $headerName, $value));
}
echo $final;
return false;
}
return true;
}
/**
* install_insert_db
*
* Inserts the database using the values from Config.
* @param string $db_user
* @param string $db_pass
* @param bool $create_db
* @param bool $overwrite
* @param bool $create_tables
* @param string $charset
* @param string $collation
*/
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'): bool
{
$database = (string) AmpConfig::get('database_name');
// Make sure that the database name is valid
preg_match('/([^\d\w\_\-])/', $database, $matches);
if (count($matches)) {
AmpError::add('general', T_('Database name is invalid'));
return false;
}
if (!Dba::check_database()) {
/* HINT: Database error message */
AmpError::add('general', sprintf(T_('Unable to connect to the database: %s'), Dba::error()));
return false;
}
$db_exists = Dba::read('SHOW TABLES');
if (is_object($db_exists) && $create_db) {
if ($overwrite) {
Dba::write('DROP DATABASE `' . $database . '`');
} else {
AmpError::add('general', T_('Database already exists and "overwrite" was not checked'));
return false;
}
}
if ($create_db) {
if (!Dba::write('CREATE DATABASE `' . $database . '`')) {
/* HINT: Database error message */
AmpError::add('general', sprintf(T_('Unable to create the database: %s'), Dba::error()));
return false;
}
}
Dba::disconnect();
// Check to see if we should create a user here
if (strlen((string) $db_user) && strlen((string) $db_pass)) {
$db_host = AmpConfig::get('database_hostname');
// create the user account
$sql_user = "CREATE USER '" . Dba::escape($db_user) . "'";
if ($db_host == 'localhost' || strpos($db_host, '/') === 0) {
$sql_user .= "@'localhost'";
}
$sql_user .= " IDENTIFIED BY '" . Dba::escape($db_pass) . "'";
if (!Dba::write($sql_user)) {
AmpError::add('general', sprintf(
/* HINT: %1 user, %2 database, %3 host, %4 error message */
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()
));
// this user might exist but we don't always care
if (!$overwrite) {
return false;
}
}
// grant database access to that account
$sql_grant = "GRANT ALL PRIVILEGES ON `" . Dba::escape($database) . "`.* TO '" . Dba::escape($db_user) . "'";
if ($db_host == 'localhost' || strpos($db_host, '/') === 0) {
$sql_grant .= "@'localhost'";
}
$sql_grant .= " WITH GRANT OPTION";
if (!Dba::write($sql_grant)) {
AmpError::add('general', sprintf(
/* HINT: %1 database, %2 user, %3 host, %4 error message */
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()
));
return false;
}
} // end if we are creating a user
if ($create_tables) {
$sql_file = __DIR__ . '/../../../resources/sql/ampache.sql';
$query = fread(fopen($sql_file, 'r'), Core::get_filesize($sql_file));
$pieces = $this->split_sql($query);
$p_count = count($pieces);
$errors = array();
for ($count = 0; $count < $p_count; $count++) {
$pieces[$count] = trim((string) $pieces[$count]);
if (!empty($pieces[$count]) && $pieces[$count] != '#') {
if (!Dba::write($pieces[$count])) {
$errors[] = array(Dba::error(), $pieces[$count]);
}
}
}
}
if ($create_db) {
$sql = "ALTER DATABASE `" . $database . "` DEFAULT CHARACTER SET $charset COLLATE " . $collation;
Dba::write($sql);
// if you've set a custom collation we need to change it
$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");
foreach ($tables as $table_name) {
$sql = "ALTER TABLE `" . $table_name . "` CHARACTER SET $charset COLLATE " . $collation;
Dba::write($sql);
}
}
// If they've picked something other than English update default preferences
if (AmpConfig::get('lang', 'en_US') != 'en_US') {
// FIXME: 31? I hate magic.
$sql = 'UPDATE `preference` SET `value` = ? WHERE `id` = 31';
Dba::write($sql, array(AmpConfig::get('lang', 'en_US')));
$sql = 'UPDATE `user_preference` SET `value` = ? WHERE `preference` = 31';
Dba::write($sql, array(AmpConfig::get('lang', 'en_US')));
}
return true;
}
/**
* Attempts to write out the config file or offer it as a download.
* @param bool $download
* @return bool
* @throws Exception
*/
public function install_create_config($download = false): bool
{
$config_file = __DIR__ . '/../../../config/ampache.cfg.php';
/* Attempt to make DB connection */
Dba::dbh();
$params = AmpConfig::get_all();
if (empty($params['database_username']) || (empty($params['database_password']) && strpos($params['database_hostname'], '/') !== 0)) {
AmpError::add('general', T_("Invalid configuration settings"));
return false;
}
// Connect to the DB
if (!Dba::check_database()) {
AmpError::add('general', T_("Connection to the database failed: Check hostname, username and password"));
return false;
}
$final = $this->generate_config($params);
// Make sure the directory is writable OR the empty config file is
if (!$download) {
if (!check_config_writable()) {
AmpError::add('general', T_('Config file is not writable'));
return false;
} else {
// Given that $final is > 0, we can ignore lazy comparison problems
if (!file_put_contents($config_file, $final)) {
AmpError::add('general', T_('Failed writing config file'));
return false;
}
}
} else {
$browser = new Horde_Browser();
$headers = $browser->getDownloadHeaders('ampache.cfg.php', 'text/plain', false, strlen((string) $final));
foreach ($headers as $headerName => $value) {
header(sprintf('%s: %s', $headerName, $value));
}
echo $final;
return false;
}
return true;
}
/**
* this creates your initial account and sets up the preferences for the -1 user and you
* @param string $username
* @param string $password
* @param string $password2
*/
public function install_create_account($username, $password, $password2): bool
{
if (!strlen((string) $username) || !strlen((string) $password)) {
AmpError::add('general', T_('No username or password was specified'));
return false;
}
if ($password !== $password2) {
AmpError::add('general', T_('Passwords do not match'));
return false;
}
if (!Dba::check_database()) {
/* HINT: Database error message */
AmpError::add('general', sprintf(T_('Connection to the database failed: %s'), Dba::error()));
return false;
}
if (!Dba::check_database_inserted()) {
/* HINT: Database error message */
AmpError::add('general', sprintf(T_('Database select failed: %s'), Dba::error()));
return false;
}
$user_id = User::create($username, 'Administrator', '', '', $password, 100);
if ($user_id < 1) {
/* HINT: Database error message */
AmpError::add('general', sprintf(T_('Administrative user creation failed: %s'), Dba::error()));
return false;
}
// Fix the system user preferences
User::fix_preferences('-1');
return true;
}
/**
* @param string $command
*/
private function command_exists($command): bool
{
if (!function_exists('proc_open')) {
return false;
}
$whereIsCommand = (PHP_OS == 'WINNT') ? 'where' : 'which';
$process = proc_open(
"$whereIsCommand $command",
array(
0 => array("pipe", "r"), // STDIN
1 => array("pipe", "w"), // STDOUT
2 => array("pipe", "w"), // STDERR
),
$pipes
);
if ($process !== false) {
$stdout = stream_get_contents($pipes[1]);
stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return $stdout != '';
}
return false;
}
/**
* get transcode modes available on this machine.
* @return array
*/
public function install_get_transcode_modes(): array
{
$modes = array();
if ($this->command_exists('ffmpeg')) {
$modes[] = 'ffmpeg';
}
if ($this->command_exists('avconv')) {
$modes[] = 'avconv';
}
return $modes;
}
/**
* @param $mode
*/
public function install_config_transcode_mode($mode)
{
$trconfig = array(
'encode_target' => 'mp3',
'encode_video_target' => 'webm',
'transcode_m4a' => 'required',
'transcode_flac' => 'required',
'transcode_mpc' => 'required',
'transcode_ogg' => 'allowed',
'transcode_wav' => 'required',
'transcode_avi' => 'allowed',
'transcode_mpg' => 'allowed',
'transcode_mkv' => 'allowed',
);
if ($mode == 'ffmpeg' || $mode == 'avconv') {
$trconfig['transcode_cmd'] = $mode;
$trconfig['transcode_input'] = '-i %FILE%';
$trconfig['waveform'] = 'true';
$trconfig['generate_video_preview'] = 'true';
AmpConfig::set_by_array($trconfig, true);
}
}
/**
* @param $case
*/
public function install_config_use_case($case)
{
$trconfig = array(
'use_auth' => 'true',
'ratings' => 'true',
'sociable' => 'true',
'licensing' => 'false',
'wanted' => 'false',
'live_stream' => 'true',
'allow_public_registration' => 'false',
'cookie_disclaimer' => 'false',
'share' => 'false'
);
$dbconfig = array(
'download' => '1',
'share' => '0',
'allow_video' => '0',
'home_now_playing' => '1',
'home_recently_played' => '1'
);
switch ($case) {
case 'minimalist':
$trconfig['ratings'] = 'false';
$trconfig['sociable'] = 'false';
$trconfig['wanted'] = 'false';
$trconfig['live_stream'] = 'false';
$dbconfig['download'] = '0';
$dbconfig['allow_video'] = '0';
$cookie_options = [
'expires' => time() + (30 * 24 * 60 * 60),
'path' => '/',
'samesite' => 'Strict'
];
// Default local UI preferences to have a better 'minimalist first look'.
setcookie('sidebar_state', 'collapsed', $cookie_options);
setcookie('browse_album_grid_view', 'false', $cookie_options);
setcookie('browse_artist_grid_view', 'false', $cookie_options);
break;
case 'community':
$trconfig['use_auth'] = 'false';
$trconfig['licensing'] = 'true';
$trconfig['wanted'] = 'false';
$trconfig['live_stream'] = 'false';
$trconfig['allow_public_registration'] = 'true';
$trconfig['cookie_disclaimer'] = 'true';
$trconfig['share'] = 'true';
$dbconfig['download'] = '0';
$dbconfig['share'] = '1';
$dbconfig['home_now_playing'] = '0';
$dbconfig['home_recently_played'] = '0';
break;
}
AmpConfig::set_by_array($trconfig, true);
foreach ($dbconfig as $preference => $value) {
Preference::update($preference, -1, $value, true, true);
}
}
/**
* @param array $backends
*/
public function install_config_backends(array $backends)
{
$dbconfig = array(
'subsonic_backend' => '0',
'daap_backend' => '0',
'upnp_backend' => '0',
'webdav_backend' => '0',
'stream_beautiful_url' => '0'
);
foreach ($backends as $backend) {
switch ($backend) {
case 'subsonic':
$dbconfig['subsonic_backend'] = '1';
break;
case 'upnp':
$dbconfig['upnp_backend'] = '1';
$dbconfig['stream_beautiful_url'] = '1';
break;
case 'daap':
$dbconfig['daap_backend'] = '1';
break;
case 'webdav':
$dbconfig['webdav_backend'] = '1';
break;
}
}
foreach ($dbconfig as $preference => $value) {
Preference::update($preference, -1, $value, true, true);
}
}
/**
* Write new configuration into the current configuration file by keeping old values.
*/
public function write_config(string $current_file_path): bool
{
if (!is_writeable($current_file_path)) {
return false;
}
$new_data = $this->generate_config(parse_ini_file($current_file_path));
// Start writing into the current config file
$handle = fopen($current_file_path, 'w+');
fwrite($handle, $new_data, strlen((string) $new_data));
fclose($handle);
return true;
}
/**
* This takes an array of results and re-generates the config file
* this is used by the installer and by the admin/system page
* @param array $current
* @return string
* @throws Exception
*/
public function generate_config(array $current): string
{
// Start building the new config file
$distfile = __DIR__ . '/../../../config/ampache.cfg.php.dist';
$handle = fopen($distfile, 'r');
$dist = fread($handle, Core::get_filesize($distfile));
fclose($handle);
$data = explode("\n", (string) $dist);
$final = "";
foreach ($data as $line) {
if (
preg_match("/^;?([\w\d]+)\s+=\s+[\"]{1}(.*?)[\"]{1}$/", $line, $matches) ||
preg_match("/^;?([\w\d]+)\s+=\s+[\']{1}(.*?)[\']{1}$/", $line, $matches) ||
preg_match("/^;?([\w\d]+)\s+=\s+[\'\"]{0}(.*)[\'\"]{0}$/", $line, $matches) ||
preg_match("/^;?([\w\d]+)\s{0}=\s{0}[\'\"]?(.*?)[\'\"]?$/", $line, $matches)
) {
$key = $matches[1];
$value = $matches[2];
// Put in the current value
if ($key == 'config_version') {
$line = $key . ' = ' . $this->escape_ini($value);
} elseif ($key == 'secret_key' && !isset($current[$key])) {
$secret_key = Core::gen_secure_token(31);
if ($secret_key !== false) {
$line = $key . ' = "' . $this->escape_ini($secret_key) . '"';
}
} elseif (isset($current[$key])) {
// unable to generate a cryptographically secure token, use the default one
$line = $key . ' = "' . $this->escape_ini((string) $current[$key]) . '"';
unset($current[$key]);
}
}
$final .= $line . "\n";
}
return $final;
}
/**
* Escape a value used for inserting into an ini file.
* Won't quote ', like addslashes does.
* @param string|string[] $str
* @return string|string[]
*/
private function escape_ini($str)
{
return str_replace('"', '\"', $str);
}
}