* @author Bart Visscher * @author Bernhard Posselt * @author Bjoern Schiessle * @author Brice Maron * @author Christoph Wurst * @author Dan Callahan * @author Daniel Kesselberg * @author François Kubler * @author Frank Isemann * @author Jakob Sack * @author Joas Schilling * @author Julius Härtl * @author KB7777 * @author Kevin Lanni * @author Lukas Reschke * @author MichaIng <28480705+MichaIng@users.noreply.github.com> * @author MichaIng * @author Morris Jobke * @author Robin Appelman * @author Roeland Jago Douma * @author Sean Comeau * @author Serge Martin * @author Simounet * @author Thomas Müller * @author Valdnet <47037905+Valdnet@users.noreply.github.com> * @author Vincent Petry * * @license AGPL-3.0 * * This code is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, version 3, * as published by the Free Software Foundation. * * 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, version 3, * along with this program. If not, see * */ namespace OC; use bantu\IniGetWrapper\IniGetWrapper; use Exception; use InvalidArgumentException; use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Authentication\Token\TokenCleanupJob; use OC\Log\Rotate; use OC\Preview\BackgroundCleanupJob; use OC\TextProcessing\RemoveOldTasksBackgroundJob; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; use OCP\Defaults; use OCP\IAppConfig; use OCP\IConfig; use OCP\IGroup; use OCP\IGroupManager; use OCP\IL10N; use OCP\IRequest; use OCP\IUserManager; use OCP\IUserSession; use OCP\L10N\IFactory as IL10NFactory; use OCP\Migration\IOutput; use OCP\Security\ISecureRandom; use OCP\Server; use Psr\Log\LoggerInterface; class Setup { protected IL10N $l10n; public function __construct( protected SystemConfig $config, protected IniGetWrapper $iniWrapper, IL10NFactory $l10nFactory, protected Defaults $defaults, protected LoggerInterface $logger, protected ISecureRandom $random, protected Installer $installer ) { $this->l10n = $l10nFactory->get('lib'); } protected static array $dbSetupClasses = [ 'mysql' => \OC\Setup\MySQL::class, 'pgsql' => \OC\Setup\PostgreSQL::class, 'oci' => \OC\Setup\OCI::class, 'sqlite' => \OC\Setup\Sqlite::class, 'sqlite3' => \OC\Setup\Sqlite::class, ]; /** * Wrapper around the "class_exists" PHP function to be able to mock it */ protected function class_exists(string $name): bool { return class_exists($name); } /** * Wrapper around the "is_callable" PHP function to be able to mock it */ protected function is_callable(string $name): bool { return is_callable($name); } /** * Wrapper around \PDO::getAvailableDrivers */ protected function getAvailableDbDriversForPdo(): array { if (class_exists(\PDO::class)) { return \PDO::getAvailableDrivers(); } return []; } /** * Get the available and supported databases of this instance * * @return array * @throws Exception */ public function getSupportedDatabases(bool $allowAllDatabases = false): array { $availableDatabases = [ 'sqlite' => [ 'type' => 'pdo', 'call' => 'sqlite', 'name' => 'SQLite', ], 'mysql' => [ 'type' => 'pdo', 'call' => 'mysql', 'name' => 'MySQL/MariaDB', ], 'pgsql' => [ 'type' => 'pdo', 'call' => 'pgsql', 'name' => 'PostgreSQL', ], 'oci' => [ 'type' => 'function', 'call' => 'oci_connect', 'name' => 'Oracle', ], ]; if ($allowAllDatabases) { $configuredDatabases = array_keys($availableDatabases); } else { $configuredDatabases = $this->config->getValue('supportedDatabases', ['sqlite', 'mysql', 'pgsql']); } if (!is_array($configuredDatabases)) { throw new Exception('Supported databases are not properly configured.'); } $supportedDatabases = []; foreach ($configuredDatabases as $database) { if (array_key_exists($database, $availableDatabases)) { $working = false; $type = $availableDatabases[$database]['type']; $call = $availableDatabases[$database]['call']; if ($type === 'function') { $working = $this->is_callable($call); } elseif ($type === 'pdo') { $working = in_array($call, $this->getAvailableDbDriversForPdo(), true); } if ($working) { $supportedDatabases[$database] = $availableDatabases[$database]['name']; } } } return $supportedDatabases; } /** * Gathers system information like database type and does * a few system checks. * * @return array of system info, including an "errors" value * in case of errors/warnings */ public function getSystemInfo(bool $allowAllDatabases = false): array { $databases = $this->getSupportedDatabases($allowAllDatabases); $dataDir = $this->config->getValue('datadirectory', \OC::$SERVERROOT . '/data'); $errors = []; // Create data directory to test whether the .htaccess works // Notice that this is not necessarily the same data directory as the one // that will effectively be used. if (!file_exists($dataDir)) { @mkdir($dataDir); } $htAccessWorking = true; if (is_dir($dataDir) && is_writable($dataDir)) { // Protect data directory here, so we can test if the protection is working self::protectDataDirectory(); try { $util = new \OC_Util(); $htAccessWorking = $util->isHtaccessWorking(Server::get(IConfig::class)); } catch (\OCP\HintException $e) { $errors[] = [ 'error' => $e->getMessage(), 'exception' => $e, 'hint' => $e->getHint(), ]; $htAccessWorking = false; } } if (\OC_Util::runningOnMac()) { $errors[] = [ 'error' => $this->l10n->t( 'Mac OS X is not supported and %s will not work properly on this platform. ' . 'Use it at your own risk! ', [$this->defaults->getProductName()] ), 'hint' => $this->l10n->t('For the best results, please consider using a GNU/Linux server instead.'), ]; } if ($this->iniWrapper->getString('open_basedir') !== '' && PHP_INT_SIZE === 4) { $errors[] = [ 'error' => $this->l10n->t( 'It seems that this %s instance is running on a 32-bit PHP environment and the open_basedir has been configured in php.ini. ' . 'This will lead to problems with files over 4 GB and is highly discouraged.', [$this->defaults->getProductName()] ), 'hint' => $this->l10n->t('Please remove the open_basedir setting within your php.ini or switch to 64-bit PHP.'), ]; } return [ 'hasSQLite' => isset($databases['sqlite']), 'hasMySQL' => isset($databases['mysql']), 'hasPostgreSQL' => isset($databases['pgsql']), 'hasOracle' => isset($databases['oci']), 'databases' => $databases, 'directory' => $dataDir, 'htaccessWorking' => $htAccessWorking, 'errors' => $errors, ]; } /** * @return array errors */ public function install(array $options, ?IOutput $output = null): array { $l = $this->l10n; $error = []; $dbType = $options['dbtype']; if (empty($options['adminlogin'])) { $error[] = $l->t('Set an admin Login.'); } if (empty($options['adminpass'])) { $error[] = $l->t('Set an admin password.'); } if (empty($options['directory'])) { $options['directory'] = \OC::$SERVERROOT . "/data"; } if (!isset(self::$dbSetupClasses[$dbType])) { $dbType = 'sqlite'; } $username = htmlspecialchars_decode($options['adminlogin']); $password = htmlspecialchars_decode($options['adminpass']); $dataDir = htmlspecialchars_decode($options['directory']); $class = self::$dbSetupClasses[$dbType]; /** @var \OC\Setup\AbstractDatabase $dbSetup */ $dbSetup = new $class($l, $this->config, $this->logger, $this->random); $error = array_merge($error, $dbSetup->validate($options)); // validate the data directory if ((!is_dir($dataDir) && !mkdir($dataDir)) || !is_writable($dataDir)) { $error[] = $l->t("Cannot create or write into the data directory %s", [$dataDir]); } if (!empty($error)) { return $error; } $request = Server::get(IRequest::class); //no errors, good if (isset($options['trusted_domains']) && is_array($options['trusted_domains'])) { $trustedDomains = $options['trusted_domains']; } else { $trustedDomains = [$request->getInsecureServerHost()]; } //use sqlite3 when available, otherwise sqlite2 will be used. if ($dbType === 'sqlite' && class_exists('SQLite3')) { $dbType = 'sqlite3'; } //generate a random salt that is used to salt the local passwords $salt = $this->random->generate(30); // generate a secret $secret = $this->random->generate(48); //write the config file $newConfigValues = [ 'passwordsalt' => $salt, 'secret' => $secret, 'trusted_domains' => $trustedDomains, 'datadirectory' => $dataDir, 'dbtype' => $dbType, 'version' => implode('.', \OCP\Util::getVersion()), ]; if ($this->config->getValue('overwrite.cli.url', null) === null) { $newConfigValues['overwrite.cli.url'] = $request->getServerProtocol() . '://' . $request->getInsecureServerHost() . \OC::$WEBROOT; } $this->config->setValues($newConfigValues); $this->outputDebug($output, 'Configuring database'); $dbSetup->initialize($options); try { $dbSetup->setupDatabase($username); } catch (\OC\DatabaseSetupException $e) { $error[] = [ 'error' => $e->getMessage(), 'exception' => $e, 'hint' => $e->getHint(), ]; return $error; } catch (Exception $e) { $error[] = [ 'error' => 'Error while trying to create admin account: ' . $e->getMessage(), 'exception' => $e, 'hint' => '', ]; return $error; } $this->outputDebug($output, 'Run server migrations'); try { // apply necessary migrations $dbSetup->runMigrations($output); } catch (Exception $e) { $error[] = [ 'error' => 'Error while trying to initialise the database: ' . $e->getMessage(), 'exception' => $e, 'hint' => '', ]; return $error; } $this->outputDebug($output, 'Create admin account'); // create the admin account and group $user = null; try { $user = Server::get(IUserManager::class)->createUser($username, $password); if (!$user) { $error[] = "Account <$username> could not be created."; return $error; } } catch (Exception $exception) { $error[] = $exception->getMessage(); return $error; } $config = Server::get(IConfig::class); $config->setAppValue('core', 'installedat', (string)microtime(true)); $appConfig = Server::get(IAppConfig::class); $appConfig->setValueInt('core', 'lastupdatedat', time()); $vendorData = $this->getVendorData(); $config->setAppValue('core', 'vendor', $vendorData['vendor']); if ($vendorData['channel'] !== 'stable') { $config->setSystemValue('updater.release.channel', $vendorData['channel']); } $group = Server::get(IGroupManager::class)->createGroup('admin'); if ($group instanceof IGroup) { $group->addUser($user); } // Install shipped apps and specified app bundles $this->outputDebug($output, 'Install default apps'); Installer::installShippedApps(false, $output); // create empty file in data dir, so we can later find // out that this is indeed an ownCloud data directory $this->outputDebug($output, 'Setup data directory'); file_put_contents($config->getSystemValueString('datadirectory', \OC::$SERVERROOT . '/data') . '/.ocdata', ''); // Update .htaccess files self::updateHtaccess(); self::protectDataDirectory(); $this->outputDebug($output, 'Install background jobs'); self::installBackgroundJobs(); //and we are done $config->setSystemValue('installed', true); if (self::shouldRemoveCanInstallFile()) { unlink(\OC::$configDir.'/CAN_INSTALL'); } $bootstrapCoordinator = \OCP\Server::get(\OC\AppFramework\Bootstrap\Coordinator::class); $bootstrapCoordinator->runInitialRegistration(); // Create a session token for the newly created user // The token provider requires a working db, so it's not injected on setup /** @var \OC\User\Session $userSession */ $userSession = Server::get(IUserSession::class); $provider = Server::get(PublicKeyTokenProvider::class); $userSession->setTokenProvider($provider); $userSession->login($username, $password); $user = $userSession->getUser(); if (!$user) { $error[] = "No account found in session."; return $error; } $userSession->createSessionToken($request, $user->getUID(), $username, $password); $session = $userSession->getSession(); $session->set('last-password-confirm', Server::get(ITimeFactory::class)->getTime()); // Set email for admin if (!empty($options['adminemail'])) { $user->setSystemEMailAddress($options['adminemail']); } return $error; } public static function installBackgroundJobs(): void { $jobList = Server::get(IJobList::class); $jobList->add(TokenCleanupJob::class); $jobList->add(Rotate::class); $jobList->add(BackgroundCleanupJob::class); $jobList->add(RemoveOldTasksBackgroundJob::class); } /** * @return string Absolute path to htaccess */ private function pathToHtaccess(): string { return \OC::$SERVERROOT . '/.htaccess'; } /** * Find webroot from config * * @throws InvalidArgumentException when invalid value for overwrite.cli.url */ private static function findWebRoot(SystemConfig $config): string { // For CLI read the value from overwrite.cli.url if (\OC::$CLI) { $webRoot = $config->getValue('overwrite.cli.url', ''); if ($webRoot === '') { throw new InvalidArgumentException('overwrite.cli.url is empty'); } if (!filter_var($webRoot, FILTER_VALIDATE_URL)) { throw new InvalidArgumentException('invalid value for overwrite.cli.url'); } $webRoot = rtrim((parse_url($webRoot, PHP_URL_PATH) ?? ''), '/'); } else { $webRoot = !empty(\OC::$WEBROOT) ? \OC::$WEBROOT : '/'; } return $webRoot; } /** * Append the correct ErrorDocument path for Apache hosts * * @return bool True when success, False otherwise * @throws \OCP\AppFramework\QueryException */ public static function updateHtaccess(): bool { $config = Server::get(SystemConfig::class); try { $webRoot = self::findWebRoot($config); } catch (InvalidArgumentException $e) { return false; } $setupHelper = Server::get(\OC\Setup::class); if (!is_writable($setupHelper->pathToHtaccess())) { return false; } $htaccessContent = file_get_contents($setupHelper->pathToHtaccess()); $content = "#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####\n"; $htaccessContent = explode($content, $htaccessContent, 2)[0]; //custom 403 error page $content .= "\nErrorDocument 403 " . $webRoot . '/index.php/error/403'; //custom 404 error page $content .= "\nErrorDocument 404 " . $webRoot . '/index.php/error/404'; // Add rewrite rules if the RewriteBase is configured $rewriteBase = $config->getValue('htaccess.RewriteBase', ''); if ($rewriteBase !== '') { $content .= "\n"; $content .= "\n Options -MultiViews"; $content .= "\n RewriteRule ^core/js/oc.js$ index.php [PT,E=PATH_INFO:$1]"; $content .= "\n RewriteRule ^core/preview.png$ index.php [PT,E=PATH_INFO:$1]"; $content .= "\n RewriteCond %{REQUEST_FILENAME} !\\.(css|js|mjs|svg|gif|png|html|otf|ttf|woff2?|ico|jpg|jpeg|map|webm|mp4|mp3|ogg|wav|flac|wasm|tflite)$"; $content .= "\n RewriteCond %{REQUEST_FILENAME} !/core/ajax/update\\.php"; $content .= "\n RewriteCond %{REQUEST_FILENAME} !/core/img/(favicon\\.ico|manifest\\.json)$"; $content .= "\n RewriteCond %{REQUEST_FILENAME} !/(cron|public|remote|status)\\.php"; $content .= "\n RewriteCond %{REQUEST_FILENAME} !/ocs/v(1|2)\\.php"; $content .= "\n RewriteCond %{REQUEST_FILENAME} !/robots\\.txt"; $content .= "\n RewriteCond %{REQUEST_FILENAME} !/(ocs-provider|updater)/"; $content .= "\n RewriteCond %{REQUEST_URI} !^/\\.well-known/(acme-challenge|pki-validation)/.*"; $content .= "\n RewriteCond %{REQUEST_FILENAME} !/richdocumentscode(_arm64)?/proxy.php$"; $content .= "\n RewriteRule . index.php [PT,E=PATH_INFO:$1]"; $content .= "\n RewriteBase " . $rewriteBase; $content .= "\n "; $content .= "\n SetEnv front_controller_active true"; $content .= "\n "; $content .= "\n DirectorySlash off"; $content .= "\n "; $content .= "\n "; $content .= "\n"; } // Never write file back if disk space should be too low if (function_exists('disk_free_space')) { $df = disk_free_space(\OC::$SERVERROOT); $size = strlen($content) + 10240; if ($df !== false && $df < (float)$size) { throw new \Exception(\OC::$SERVERROOT . " does not have enough space for writing the htaccess file! Not writing it back!"); } } //suppress errors in case we don't have permissions for it return (bool)@file_put_contents($setupHelper->pathToHtaccess(), $htaccessContent . $content . "\n"); } public static function protectDataDirectory(): void { //Require all denied $now = date('Y-m-d H:i:s'); $content = "# Generated by Nextcloud on $now\n"; $content .= "# Section for Apache 2.4 to 2.6\n"; $content .= "\n"; $content .= " Require all denied\n"; $content .= "\n"; $content .= "\n"; $content .= " Order Allow,Deny\n"; $content .= " Deny from all\n"; $content .= " Satisfy All\n"; $content .= "\n\n"; $content .= "# Section for Apache 2.2\n"; $content .= "\n"; $content .= " \n"; $content .= " \n"; $content .= " Order Allow,Deny\n"; $content .= " Deny from all\n"; $content .= " \n"; $content .= " Satisfy All\n"; $content .= " \n"; $content .= "\n\n"; $content .= "# Section for Apache 2.2 to 2.6\n"; $content .= "\n"; $content .= " IndexIgnore *\n"; $content .= ""; $baseDir = Server::get(IConfig::class)->getSystemValueString('datadirectory', \OC::$SERVERROOT . '/data'); file_put_contents($baseDir . '/.htaccess', $content); file_put_contents($baseDir . '/index.html', ''); } private function getVendorData(): array { // this should really be a JSON file require \OC::$SERVERROOT . '/version.php'; /** @var mixed $vendor */ /** @var mixed $OC_Channel */ return [ 'vendor' => (string)$vendor, 'channel' => (string)$OC_Channel, ]; } public function shouldRemoveCanInstallFile(): bool { return \OC_Util::getChannel() !== 'git' && is_file(\OC::$configDir.'/CAN_INSTALL'); } public function canInstallFileExists(): bool { return is_file(\OC::$configDir.'/CAN_INSTALL'); } protected function outputDebug(?IOutput $output, string $message): void { if ($output instanceof IOutput) { $output->debug($message); } } }