diff options
Diffstat (limited to 'lib/private/Setup')
-rw-r--r-- | lib/private/Setup/AbstractDatabase.php | 138 | ||||
-rw-r--r-- | lib/private/Setup/MySQL.php | 197 | ||||
-rw-r--r-- | lib/private/Setup/OCI.php | 88 | ||||
-rw-r--r-- | lib/private/Setup/PostgreSQL.php | 169 | ||||
-rw-r--r-- | lib/private/Setup/Sqlite.php | 59 |
5 files changed, 651 insertions, 0 deletions
diff --git a/lib/private/Setup/AbstractDatabase.php b/lib/private/Setup/AbstractDatabase.php new file mode 100644 index 00000000000..8f6294faa66 --- /dev/null +++ b/lib/private/Setup/AbstractDatabase.php @@ -0,0 +1,138 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Setup; + +use OC\DB\Connection; +use OC\DB\ConnectionFactory; +use OC\DB\MigrationService; +use OC\SystemConfig; +use OCP\IL10N; +use OCP\Migration\IOutput; +use OCP\Security\ISecureRandom; +use Psr\Log\LoggerInterface; + +abstract class AbstractDatabase { + /** @var IL10N */ + protected $trans; + /** @var string */ + protected $dbUser; + /** @var string */ + protected $dbPassword; + /** @var string */ + protected $dbName; + /** @var string */ + protected $dbHost; + /** @var string */ + protected $dbPort; + /** @var string */ + protected $tablePrefix; + /** @var SystemConfig */ + protected $config; + /** @var LoggerInterface */ + protected $logger; + /** @var ISecureRandom */ + protected $random; + /** @var bool */ + protected $tryCreateDbUser; + + public function __construct(IL10N $trans, SystemConfig $config, LoggerInterface $logger, ISecureRandom $random) { + $this->trans = $trans; + $this->config = $config; + $this->logger = $logger; + $this->random = $random; + } + + public function validate($config) { + $errors = []; + if (empty($config['dbuser']) && empty($config['dbname'])) { + $errors[] = $this->trans->t('Enter the database Login and name for %s', [$this->dbprettyname]); + } elseif (empty($config['dbuser'])) { + $errors[] = $this->trans->t('Enter the database Login for %s', [$this->dbprettyname]); + } elseif (empty($config['dbname'])) { + $errors[] = $this->trans->t('Enter the database name for %s', [$this->dbprettyname]); + } + if (substr_count($config['dbname'], '.') >= 1) { + $errors[] = $this->trans->t('You cannot use dots in the database name %s', [$this->dbprettyname]); + } + return $errors; + } + + public function initialize($config) { + $dbUser = $config['dbuser']; + $dbPass = $config['dbpass']; + $dbName = $config['dbname']; + $dbHost = !empty($config['dbhost']) ? $config['dbhost'] : 'localhost'; + $dbPort = !empty($config['dbport']) ? $config['dbport'] : ''; + $dbTablePrefix = $config['dbtableprefix'] ?? 'oc_'; + + $createUserConfig = $this->config->getValue('setup_create_db_user', true); + // accept `false` both as bool and string, since setting config values from env will result in a string + $this->tryCreateDbUser = $createUserConfig !== false && $createUserConfig !== 'false'; + + $this->config->setValues([ + 'dbname' => $dbName, + 'dbhost' => $dbHost, + 'dbtableprefix' => $dbTablePrefix, + ]); + + $this->dbUser = $dbUser; + $this->dbPassword = $dbPass; + $this->dbName = $dbName; + $this->dbHost = $dbHost; + $this->dbPort = $dbPort; + $this->tablePrefix = $dbTablePrefix; + } + + /** + * @param array $configOverwrite + * @return \OC\DB\Connection + */ + protected function connect(array $configOverwrite = []): Connection { + $connectionParams = [ + 'host' => $this->dbHost, + 'user' => $this->dbUser, + 'password' => $this->dbPassword, + 'tablePrefix' => $this->tablePrefix, + 'dbname' => $this->dbName + ]; + + // adding port support through installer + if (!empty($this->dbPort)) { + if (ctype_digit($this->dbPort)) { + $connectionParams['port'] = $this->dbPort; + } else { + $connectionParams['unix_socket'] = $this->dbPort; + } + } elseif (strpos($this->dbHost, ':')) { + // Host variable may carry a port or socket. + [$host, $portOrSocket] = explode(':', $this->dbHost, 2); + if (ctype_digit($portOrSocket)) { + $connectionParams['port'] = $portOrSocket; + } else { + $connectionParams['unix_socket'] = $portOrSocket; + } + $connectionParams['host'] = $host; + } + $connectionParams = array_merge($connectionParams, $configOverwrite); + $connectionParams = array_merge($connectionParams, ['primary' => $connectionParams, 'replica' => [$connectionParams]]); + $cf = new ConnectionFactory($this->config); + $connection = $cf->getConnection($this->config->getValue('dbtype', 'sqlite'), $connectionParams); + $connection->ensureConnectedToPrimary(); + return $connection; + } + + abstract public function setupDatabase(); + + public function runMigrations(?IOutput $output = null) { + if (!is_dir(\OC::$SERVERROOT . '/core/Migrations')) { + return; + } + $ms = new MigrationService('core', \OC::$server->get(Connection::class), $output); + $ms->migrate('latest', true); + } +} diff --git a/lib/private/Setup/MySQL.php b/lib/private/Setup/MySQL.php new file mode 100644 index 00000000000..c4794a86743 --- /dev/null +++ b/lib/private/Setup/MySQL.php @@ -0,0 +1,197 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Setup; + +use Doctrine\DBAL\Platforms\MySQL80Platform; +use Doctrine\DBAL\Platforms\MySQL84Platform; +use OC\DB\ConnectionAdapter; +use OC\DB\MySqlTools; +use OCP\IDBConnection; +use OCP\Security\ISecureRandom; + +class MySQL extends AbstractDatabase { + public $dbprettyname = 'MySQL/MariaDB'; + + public function setupDatabase() { + //check if the database user has admin right + $connection = $this->connect(['dbname' => null]); + + // detect mb4 + $tools = new MySqlTools(); + if ($tools->supports4ByteCharset(new ConnectionAdapter($connection))) { + $this->config->setValue('mysql.utf8mb4', true); + $connection = $this->connect(['dbname' => null]); + } + + if ($this->tryCreateDbUser) { + $this->createSpecificUser('oc_admin', new ConnectionAdapter($connection)); + } + + $this->config->setValues([ + 'dbuser' => $this->dbUser, + 'dbpassword' => $this->dbPassword, + ]); + + //create the database + $this->createDatabase($connection); + + //fill the database if needed + $query = 'select count(*) from information_schema.tables where table_schema=? AND table_name = ?'; + $connection->executeQuery($query, [$this->dbName, $this->tablePrefix . 'users']); + + $connection->close(); + $connection = $this->connect(); + try { + $connection->connect(); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), [ + 'exception' => $e, + ]); + throw new \OC\DatabaseSetupException($this->trans->t('MySQL Login and/or password not valid'), + $this->trans->t('You need to enter details of an existing account.'), 0, $e); + } + } + + /** + * @param \OC\DB\Connection $connection + */ + private function createDatabase($connection): void { + try { + $name = $this->dbName; + $user = $this->dbUser; + //we can't use OC_DB functions here because we need to connect as the administrative user. + $characterSet = $this->config->getValue('mysql.utf8mb4', false) ? 'utf8mb4' : 'utf8'; + $query = "CREATE DATABASE IF NOT EXISTS `$name` CHARACTER SET $characterSet COLLATE {$characterSet}_bin;"; + $connection->executeUpdate($query); + } catch (\Exception $ex) { + $this->logger->error('Database creation failed.', [ + 'exception' => $ex, + 'app' => 'mysql.setup', + ]); + return; + } + + try { + //this query will fail if there aren't the right permissions, ignore the error + $query = "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, REFERENCES, INDEX, ALTER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, EVENT, TRIGGER ON `$name` . * TO '$user'"; + $connection->executeUpdate($query); + } catch (\Exception $ex) { + $this->logger->debug('Could not automatically grant privileges, this can be ignored if database user already had privileges.', [ + 'exception' => $ex, + 'app' => 'mysql.setup', + ]); + } + } + + /** + * @param IDBConnection $connection + * @throws \OC\DatabaseSetupException + */ + private function createDBUser($connection): void { + $name = $this->dbUser; + $password = $this->dbPassword; + + try { + // we need to create 2 accounts, one for global use and one for local user. if we don't specify the local one, + // the anonymous user would take precedence when there is one. + + if ($connection->getDatabasePlatform() instanceof MySQL84Platform) { + $query = "CREATE USER ?@'localhost' IDENTIFIED WITH caching_sha2_password BY ?"; + $connection->executeStatement($query, [$name,$password]); + $query = "CREATE USER ?@'%' IDENTIFIED WITH caching_sha2_password BY ?"; + $connection->executeStatement($query, [$name,$password]); + } elseif ($connection->getDatabasePlatform() instanceof Mysql80Platform) { + // TODO: Remove this elseif section as soon as MySQL 8.0 is out-of-support (after April 2026) + $query = "CREATE USER ?@'localhost' IDENTIFIED WITH mysql_native_password BY ?"; + $connection->executeStatement($query, [$name,$password]); + $query = "CREATE USER ?@'%' IDENTIFIED WITH mysql_native_password BY ?"; + $connection->executeStatement($query, [$name,$password]); + } else { + $query = "CREATE USER ?@'localhost' IDENTIFIED BY ?"; + $connection->executeStatement($query, [$name,$password]); + $query = "CREATE USER ?@'%' IDENTIFIED BY ?"; + $connection->executeStatement($query, [$name,$password]); + } + } catch (\Exception $ex) { + $this->logger->error('Database user creation failed.', [ + 'exception' => $ex, + 'app' => 'mysql.setup', + ]); + throw $ex; + } + } + + /** + * @param string $username + * @param IDBConnection $connection + */ + private function createSpecificUser($username, $connection): void { + $rootUser = $this->dbUser; + $rootPassword = $this->dbPassword; + + //create a random password so we don't need to store the admin password in the config file + $saveSymbols = str_replace(['\"', '\\', '\'', '`'], '', ISecureRandom::CHAR_SYMBOLS); + $password = $this->random->generate(22, ISecureRandom::CHAR_ALPHANUMERIC . $saveSymbols) + . $this->random->generate(2, ISecureRandom::CHAR_UPPER) + . $this->random->generate(2, ISecureRandom::CHAR_LOWER) + . $this->random->generate(2, ISecureRandom::CHAR_DIGITS) + . $this->random->generate(2, $saveSymbols); + $this->dbPassword = str_shuffle($password); + + try { + //user already specified in config + $oldUser = $this->config->getValue('dbuser', false); + + //we don't have a dbuser specified in config + if ($this->dbUser !== $oldUser) { + //add prefix to the admin username to prevent collisions + $adminUser = substr('oc_' . $username, 0, 16); + + $i = 1; + while (true) { + //this should be enough to check for admin rights in mysql + $query = 'SELECT user FROM mysql.user WHERE user=?'; + $result = $connection->executeQuery($query, [$adminUser]); + + //current dbuser has admin rights + $data = $result->fetchAll(); + $result->closeCursor(); + //new dbuser does not exist + if (count($data) === 0) { + //use the admin login data for the new database user + $this->dbUser = $adminUser; + $this->createDBUser($connection); + // if sharding is used we need to manually call this for every shard as those also need the user setup! + /** @var ConnectionAdapter $connection */ + foreach ($connection->getInner()->getShardConnections() as $shard) { + $this->createDBUser($shard); + } + + break; + } else { + //repeat with different username + $length = strlen((string)$i); + $adminUser = substr('oc_' . $username, 0, 16 - $length) . $i; + $i++; + } + } + } else { + // Reuse existing password if a database config is already present + $this->dbPassword = $rootPassword; + } + } catch (\Exception $ex) { + $this->logger->info('Can not create a new MySQL user, will continue with the provided user.', [ + 'exception' => $ex, + 'app' => 'mysql.setup', + ]); + // Restore the original credentials + $this->dbUser = $rootUser; + $this->dbPassword = $rootPassword; + } + } +} diff --git a/lib/private/Setup/OCI.php b/lib/private/Setup/OCI.php new file mode 100644 index 00000000000..61c7f968787 --- /dev/null +++ b/lib/private/Setup/OCI.php @@ -0,0 +1,88 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Setup; + +class OCI extends AbstractDatabase { + public $dbprettyname = 'Oracle'; + + protected $dbtablespace; + + public function initialize($config) { + parent::initialize($config); + if (array_key_exists('dbtablespace', $config)) { + $this->dbtablespace = $config['dbtablespace']; + } else { + $this->dbtablespace = 'USERS'; + } + // allow empty hostname for oracle + $this->dbHost = $config['dbhost']; + + $this->config->setValues([ + 'dbhost' => $this->dbHost, + 'dbtablespace' => $this->dbtablespace, + ]); + } + + public function validate($config) { + $errors = []; + if (empty($config['dbuser']) && empty($config['dbname'])) { + $errors[] = $this->trans->t('Enter the database Login and name for %s', [$this->dbprettyname]); + } elseif (empty($config['dbuser'])) { + $errors[] = $this->trans->t('Enter the database Login for %s', [$this->dbprettyname]); + } elseif (empty($config['dbname'])) { + $errors[] = $this->trans->t('Enter the database name for %s', [$this->dbprettyname]); + } + return $errors; + } + + public function setupDatabase() { + try { + $this->connect(); + } catch (\Exception $e) { + $errorMessage = $this->getLastError(); + if ($errorMessage) { + throw new \OC\DatabaseSetupException($this->trans->t('Oracle connection could not be established'), + $errorMessage . ' Check environment: ORACLE_HOME=' . getenv('ORACLE_HOME') + . ' ORACLE_SID=' . getenv('ORACLE_SID') + . ' LD_LIBRARY_PATH=' . getenv('LD_LIBRARY_PATH') + . ' NLS_LANG=' . getenv('NLS_LANG') + . ' tnsnames.ora is ' . (is_readable(getenv('ORACLE_HOME') . '/network/admin/tnsnames.ora') ? '' : 'not ') . 'readable', 0, $e); + } + throw new \OC\DatabaseSetupException($this->trans->t('Oracle Login and/or password not valid'), + 'Check environment: ORACLE_HOME=' . getenv('ORACLE_HOME') + . ' ORACLE_SID=' . getenv('ORACLE_SID') + . ' LD_LIBRARY_PATH=' . getenv('LD_LIBRARY_PATH') + . ' NLS_LANG=' . getenv('NLS_LANG') + . ' tnsnames.ora is ' . (is_readable(getenv('ORACLE_HOME') . '/network/admin/tnsnames.ora') ? '' : 'not ') . 'readable', 0, $e); + } + + $this->config->setValues([ + 'dbuser' => $this->dbUser, + 'dbname' => $this->dbName, + 'dbpassword' => $this->dbPassword, + ]); + } + + /** + * @param resource $connection + * @return string + */ + protected function getLastError($connection = null) { + if ($connection) { + $error = oci_error($connection); + } else { + $error = oci_error(); + } + foreach (['message', 'code'] as $key) { + if (isset($error[$key])) { + return $error[$key]; + } + } + return ''; + } +} diff --git a/lib/private/Setup/PostgreSQL.php b/lib/private/Setup/PostgreSQL.php new file mode 100644 index 00000000000..9a686db2e54 --- /dev/null +++ b/lib/private/Setup/PostgreSQL.php @@ -0,0 +1,169 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Setup; + +use OC\DatabaseException; +use OC\DB\Connection; +use OC\DB\QueryBuilder\Literal; +use OCP\Security\ISecureRandom; + +class PostgreSQL extends AbstractDatabase { + public $dbprettyname = 'PostgreSQL'; + + /** + * @throws \OC\DatabaseSetupException + */ + public function setupDatabase() { + try { + $connection = $this->connect([ + 'dbname' => 'postgres' + ]); + if ($this->tryCreateDbUser) { + //check for roles creation rights in postgresql + $builder = $connection->getQueryBuilder(); + $builder->automaticTablePrefix(false); + $query = $builder + ->select('rolname') + ->from('pg_roles') + ->where($builder->expr()->eq('rolcreaterole', new Literal('TRUE'))) + ->andWhere($builder->expr()->eq('rolname', $builder->createNamedParameter($this->dbUser))); + + try { + $result = $query->execute(); + $canCreateRoles = $result->rowCount() > 0; + } catch (DatabaseException $e) { + $canCreateRoles = false; + } + + if ($canCreateRoles) { + $connectionMainDatabase = $this->connect(); + //use the admin login data for the new database user + + //add prefix to the postgresql user name to prevent collisions + $this->dbUser = 'oc_admin'; + //create a new password so we don't need to store the admin config in the config file + $this->dbPassword = \OC::$server->get(ISecureRandom::class)->generate(30, ISecureRandom::CHAR_ALPHANUMERIC); + + $this->createDBUser($connection); + } + } + + $this->config->setValues([ + 'dbuser' => $this->dbUser, + 'dbpassword' => $this->dbPassword, + ]); + + //create the database + $this->createDatabase($connection); + // the connection to dbname=postgres is not needed anymore + $connection->close(); + + if ($this->tryCreateDbUser) { + if ($canCreateRoles) { + // Go to the main database and grant create on the public schema + // The code below is implemented to make installing possible with PostgreSQL version 15: + // https://www.postgresql.org/docs/release/15.0/ + // From the release notes: For new databases having no need to defend against insider threats, granting CREATE permission will yield the behavior of prior releases + // Therefore we assume that the database is only used by one user/service which is Nextcloud + // Additional services should get installed in a separate database in order to stay secure + // Also see https://www.postgresql.org/docs/15/ddl-schemas.html#DDL-SCHEMAS-PATTERNS + $connectionMainDatabase->executeQuery('GRANT CREATE ON SCHEMA public TO "' . addslashes($this->dbUser) . '"'); + $connectionMainDatabase->close(); + } + } + } catch (\Exception $e) { + $this->logger->warning('Error trying to connect as "postgres", assuming database is setup and tables need to be created', [ + 'exception' => $e, + ]); + $this->config->setValues([ + 'dbuser' => $this->dbUser, + 'dbpassword' => $this->dbPassword, + ]); + } + + // connect to the database (dbname=$this->dbname) and check if it needs to be filled + $this->dbUser = $this->config->getValue('dbuser'); + $this->dbPassword = $this->config->getValue('dbpassword'); + $connection = $this->connect(); + try { + $connection->connect(); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), [ + 'exception' => $e, + ]); + throw new \OC\DatabaseSetupException($this->trans->t('PostgreSQL Login and/or password not valid'), + $this->trans->t('You need to enter details of an existing account.'), 0, $e); + } + } + + private function createDatabase(Connection $connection) { + if (!$this->databaseExists($connection)) { + //The database does not exists... let's create it + $query = $connection->prepare('CREATE DATABASE ' . addslashes($this->dbName) . ' OWNER "' . addslashes($this->dbUser) . '"'); + try { + $query->execute(); + } catch (DatabaseException $e) { + $this->logger->error('Error while trying to create database', [ + 'exception' => $e, + ]); + } + } else { + $query = $connection->prepare('REVOKE ALL PRIVILEGES ON DATABASE ' . addslashes($this->dbName) . ' FROM PUBLIC'); + try { + $query->execute(); + } catch (DatabaseException $e) { + $this->logger->error('Error while trying to restrict database permissions', [ + 'exception' => $e, + ]); + } + } + } + + private function userExists(Connection $connection) { + $builder = $connection->getQueryBuilder(); + $builder->automaticTablePrefix(false); + $query = $builder->select('*') + ->from('pg_roles') + ->where($builder->expr()->eq('rolname', $builder->createNamedParameter($this->dbUser))); + $result = $query->executeQuery(); + return $result->rowCount() > 0; + } + + private function databaseExists(Connection $connection) { + $builder = $connection->getQueryBuilder(); + $builder->automaticTablePrefix(false); + $query = $builder->select('datname') + ->from('pg_database') + ->where($builder->expr()->eq('datname', $builder->createNamedParameter($this->dbName))); + $result = $query->executeQuery(); + return $result->rowCount() > 0; + } + + private function createDBUser(Connection $connection) { + $dbUser = $this->dbUser; + try { + $i = 1; + while ($this->userExists($connection)) { + $i++; + $this->dbUser = $dbUser . $i; + } + + // create the user + $query = $connection->prepare('CREATE USER "' . addslashes($this->dbUser) . "\" CREATEDB PASSWORD '" . addslashes($this->dbPassword) . "'"); + $query->execute(); + if ($this->databaseExists($connection)) { + $query = $connection->prepare('GRANT CONNECT ON DATABASE ' . addslashes($this->dbName) . ' TO "' . addslashes($this->dbUser) . '"'); + $query->execute(); + } + } catch (DatabaseException $e) { + $this->logger->error('Error while trying to create database user', [ + 'exception' => $e, + ]); + } + } +} diff --git a/lib/private/Setup/Sqlite.php b/lib/private/Setup/Sqlite.php new file mode 100644 index 00000000000..b34b1e32ede --- /dev/null +++ b/lib/private/Setup/Sqlite.php @@ -0,0 +1,59 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Setup; + +use OC\DB\ConnectionFactory; + +class Sqlite extends AbstractDatabase { + public $dbprettyname = 'Sqlite'; + + public function validate($config) { + return []; + } + + public function initialize($config) { + /* + * Web: When using web based installer its not possible to set dbname + * or dbtableprefix. Defaults used from ConnectionFactory and dbtype = 'sqlite' + * is written to config.php. + * + * Cli: When --database-name or --database-table-prefix empty or default + * dbtype = 'sqlite' is written to config.php. If you choose a value different + * from default these values are written to config.php. This is required because + * in connection factory configuration is obtained from config.php. + */ + + $this->dbName = empty($config['dbname']) + ? ConnectionFactory::DEFAULT_DBNAME + : $config['dbname']; + + $this->tablePrefix = empty($config['dbtableprefix']) + ? ConnectionFactory::DEFAULT_DBTABLEPREFIX + : $config['dbtableprefix']; + + if ($this->dbName !== ConnectionFactory::DEFAULT_DBNAME) { + $this->config->setValue('dbname', $this->dbName); + } + + if ($this->tablePrefix !== ConnectionFactory::DEFAULT_DBTABLEPREFIX) { + $this->config->setValue('dbtableprefix', $this->tablePrefix); + } + } + + public function setupDatabase() { + $datadir = $this->config->getValue( + 'datadirectory', + \OC::$SERVERROOT . '/data' + ); + + $sqliteFile = $datadir . '/' . $this->dbName . 'db'; + if (file_exists($sqliteFile)) { + unlink($sqliteFile); + } + } +} |