2 // This file is part of GNU social - https://www.gnu.org/software/social
4 // GNU social is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU Affero General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
9 // GNU social is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU Affero General Public License for more details.
14 // You should have received a copy of the GNU Affero General Public License
15 // along with GNU social. If not, see <http://www.gnu.org/licenses/>.
20 * @package Installation
21 * @author Adrian Lang <mail@adrianlang.de>
22 * @author Brenda Wallace <shiny@cpan.org>
23 * @author Brett Taylor <brett@webfroot.co.nz>
24 * @author Brion Vibber <brion@pobox.com>
25 * @author CiaranG <ciaran@ciarang.com>
26 * @author Craig Andrews <candrews@integralblue.com>
27 * @author Eric Helgeson <helfire@Erics-MBP.local>
28 * @author Evan Prodromou <evan@status.net>
29 * @author Mikael Nordfeldth <mmn@hethane.se>
30 * @author Robin Millette <millette@controlyourself.ca>
31 * @author Sarven Capadisli <csarven@status.net>
32 * @author Tom Adams <tom@holizz.com>
33 * @author Zach Copley <zach@status.net>
34 * @author Diogo Cordeiro <diogo@fc.up.pt>
35 * @copyright 2019 Free Software Foundation, Inc http://www.fsf.org
36 * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
39 abstract class Installer
58 /** Administrator info */
62 /** Should we skip writing the configuration file? */
63 public $skipConfig = false;
65 public static $dbModules = [
67 'name' => 'MariaDB 10.3+',
68 'check_module' => 'mysqli',
69 'scheme' => 'mysqli', // DSN prefix for PEAR::DB
72 'name' => 'PostgreSQL',
73 'check_module' => 'pgsql',
74 'scheme' => 'pgsql', // DSN prefix for PEAR::DB
79 * Attempt to include a PHP file and report if it worked, while
80 * suppressing the annoying warning messages on failure.
81 * @param string $filename
84 private function haveIncludeFile(string $filename): bool
86 $old = error_reporting(error_reporting() & ~E_WARNING);
87 $ok = include_once($filename);
88 error_reporting($old);
93 * Check if all is ready for installation
97 public function checkPrereqs(): bool
101 $config = INSTALLDIR . '/config.php';
102 if (!$this->skipConfig && file_exists($config)) {
103 if (!is_writable($config) || filesize($config) > 0) {
104 if (filesize($config) == 0) {
105 $this->warning('Config file "config.php" already exists and is empty, but is not writable.');
107 $this->warning('Config file "config.php" already exists.');
113 if (version_compare(PHP_VERSION, '7.3.0', '<')) {
114 $this->warning('Require PHP version 7.3.0 or greater.');
118 $reqs = ['bcmath', 'curl', 'dom', 'gd', 'intl', 'json', 'mbstring', 'openssl', 'simplexml', 'xml', 'xmlwriter'];
119 foreach ($reqs as $req) {
120 // Checks if a php extension is both installed and loaded
121 if (!extension_loaded($req)) {
122 $this->warning(sprintf('Cannot load required extension: <code>%s</code>', $req));
127 // Make sure we have at least one database module available
128 $missingExtensions = [];
129 foreach (self::$dbModules as $type => $info) {
130 if (!extension_loaded($info['check_module'])) {
131 $missingExtensions[] = $info['check_module'];
135 if (count($missingExtensions) == count(self::$dbModules)) {
136 $req = implode(', ', $missingExtensions);
137 $this->warning(sprintf('Cannot find a database extension. You need at least one of %s.', $req));
141 // @fixme this check seems to be insufficient with Windows ACLs
142 if (!$this->skipConfig && !is_writable(INSTALLDIR)) {
144 sprintf('Cannot write config file to: <code>%s</code></p>', INSTALLDIR),
145 sprintf('On your server, try this command: <code>chmod a+w %s</code>', INSTALLDIR)
150 // Check the subdirs used for file uploads
151 // TODO get another flag for this --skipFileSubdirCreation
152 if (!$this->skipConfig) {
153 define('GNUSOCIAL', true);
154 define('STATUSNET', true);
155 require_once INSTALLDIR . '/lib/language.php';
156 $_server = $this->server;
157 $_path = $this->path; // We won't be using those so it's safe to do this small hack
158 require_once INSTALLDIR . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'util.php';
159 require_once INSTALLDIR . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'default.php';
161 empty($this->avatarDir) ? $default['avatar']['dir'] : $this->avatarDir,
162 empty($this->fileDir) ? $default['attachments']['dir'] : $this->fileDir
165 foreach ($fileSubdirs as $fileFullPath) {
166 if (!file_exists($fileFullPath)) {
168 sprintf('GNU social was unable to create a directory on this path: %s', $fileFullPath),
169 'Either create that directory with the right permissions so that GNU social can use it or '.
170 'set the necessary permissions and it will be created.'
172 $pass = $pass && mkdir($fileFullPath);
173 } elseif (!is_dir($fileFullPath)) {
175 sprintf('GNU social expected a directory but found something else on this path: %s', $fileFullPath),
176 'Either make sure it goes to a directory or remove it and a directory will be created.'
179 } elseif (!is_writable($fileFullPath)) {
181 sprintf('Cannot write to directory: <code>%s</code>', $fileFullPath),
182 sprintf('On your server, try this command: <code>chmod a+w %s</code>', $fileFullPath)
192 * Basic validation on the database parameters
193 * Side effects: error output if not valid
195 * @return bool success
197 public function validateDb(): bool
201 if (empty($this->host)) {
202 $this->updateStatus("No hostname specified.", true);
206 if (empty($this->database)) {
207 $this->updateStatus("No database specified.", true);
211 if (empty($this->username)) {
212 $this->updateStatus("No username specified.", true);
216 if (empty($this->sitename)) {
217 $this->updateStatus("No sitename specified.", true);
225 * Basic validation on the administrator user parameters
226 * Side effects: error output if not valid
228 * @return bool success
230 public function validateAdmin(): bool
234 if (empty($this->adminNick)) {
235 $this->updateStatus("No initial user nickname specified.", true);
238 if ($this->adminNick && !preg_match('/^[0-9a-z]{1,64}$/', $this->adminNick)) {
239 $this->updateStatus('The user nickname "' . htmlspecialchars($this->adminNick) .
240 '" is invalid; should be plain letters and numbers no longer than 64 characters.', true);
244 // @fixme hardcoded list; should use Nickname::isValid()
245 // if/when it's safe to have loaded the infrastructure here
246 $blacklist = ['main', 'panel', 'twitter', 'settings', 'rsd.xml', 'favorited', 'featured', 'favoritedrss', 'featuredrss', 'rss', 'getfile', 'api', 'groups', 'group', 'peopletag', 'tag', 'user', 'message', 'conversation', 'notice', 'attachment', 'search', 'index.php', 'doc', 'opensearch', 'robots.txt', 'xd_receiver.html', 'facebook', 'activity'];
247 if (in_array($this->adminNick, $blacklist)) {
248 $this->updateStatus('The user nickname "' . htmlspecialchars($this->adminNick) .
249 '" is reserved.', true);
253 if (empty($this->adminPass)) {
254 $this->updateStatus("No initial user password specified.", true);
262 * Make sure a site profile was selected
264 * @return bool success
266 public function validateSiteProfile(): bool
268 if (empty($this->siteProfile)) {
269 $this->updateStatus("No site profile selected.", true);
277 * Set up the database with the appropriate function for the selected type...
278 * Saves database info into $this->db.
280 * @fixme escape things in the connection string in case we have a funny pass etc
281 * @return mixed array of database connection params on success, false on failure
284 public function setupDatabase()
287 throw new Exception("Bad order of operations: DB already set up.");
289 $this->updateStatus("Starting installation...");
291 if (empty($this->password)) {
294 $auth = ":$this->password";
296 $scheme = self::$dbModules[$this->dbtype]['scheme'];
297 $dsn = "{$scheme}://{$this->username}{$auth}@{$this->host}/{$this->database}";
299 $this->updateStatus("Checking database...");
300 $conn = $this->connectDatabase($dsn);
302 if (!$conn instanceof DB_common) {
303 // Is not the right instance
304 throw new Exception('Cannot connect to database: ' . $conn->getMessage());
307 // ensure database encoding is UTF8
308 $conn->query('SET NAMES utf8mb4');
309 if ($this->dbtype == 'mysql') {
310 $server_encoding = $conn->getRow("SHOW VARIABLES LIKE 'character_set_server'")[1];
311 if ($server_encoding != 'utf8mb4') {
312 $this->updateStatus("GNU social requires UTF8 character encoding. Your database is " . htmlentities($server_encoding));
315 } elseif ($this->dbtype == 'pgsql') {
316 $server_encoding = $conn->getRow('SHOW server_encoding')[0];
317 if ($server_encoding != 'UTF8') {
318 $this->updateStatus("GNU social requires UTF8 character encoding. Your database is " . htmlentities($server_encoding));
323 if (!is_object($conn)) {
325 throw new Exception('Fatal error: conn is no object.');
326 } elseif (!$conn instanceof DB_common) {
327 // Is not the right instance
328 throw new Exception('Cannot connect to database: ' . $conn->getMessage());
331 $res = $this->updateStatus("Creating database tables...");
332 if (!$this->createCoreTables($conn)) {
333 $this->updateStatus("Error creating tables.", true);
337 foreach (['sms_carrier' => 'SMS carrier',
338 'notice_source' => 'notice source',
339 'foreign_services' => 'foreign service']
341 $this->updateStatus(sprintf("Adding %s data to database...", $name));
342 $res = $this->runDbScript($scr . '.sql', $conn);
343 if ($res === false) {
344 $this->updateStatus(sprintf("Can't run %s script.", $name), true);
349 $db = ['type' => $this->dbtype, 'database' => $dsn];
354 * Open a connection to the database.
357 * @return DB|DB_Error
359 public function connectDatabase(string $dsn)
362 return $_DB->connect($dsn);
366 * Create core tables on the given database connection.
368 * @param DB_common $conn
371 public function createCoreTables(DB_common $conn): bool
373 $schema = Schema::get($conn);
374 $tableDefs = $this->getCoreSchema();
375 foreach ($tableDefs as $name => $def) {
376 if (defined('DEBUG_INSTALLER')) {
379 $schema->ensureTable($name, $def);
385 * Fetch the core table schema definitions.
387 * @return array of table names => table def arrays
389 public function getCoreSchema(): array
392 include INSTALLDIR . '/db/core.php';
397 * Return a parseable PHP literal for the given value.
398 * This will include quotes for strings, etc.
403 public function phpVal($val): string
405 return var_export($val, true);
409 * Return an array of parseable PHP literal for the given values.
410 * These will include quotes for strings, etc.
415 public function phpVals($map): array
417 return array_map([$this, 'phpVal'], $map);
421 * Write a stock configuration file.
423 * @return bool success
425 * @fixme escape variables in output in case we have funny chars, apostrophes etc
427 public function writeConf(): bool
429 $vals = $this->phpVals([
430 'sitename' => $this->sitename,
431 'server' => $this->server,
432 'path' => $this->path,
433 'ssl' => in_array($this->ssl, ['never', 'always'])
436 'db_database' => $this->db['database'],
437 'db_type' => $this->db['type']
440 // assemble configuration file in a string
442 "if (!defined('GNUSOCIAL')) { exit(1); }\n\n" .
445 "\$config['site']['name'] = {$vals['sitename']};\n\n" .
448 "\$config['site']['server'] = {$vals['server']};\n" .
449 "\$config['site']['path'] = {$vals['path']}; \n\n" .
450 "\$config['site']['ssl'] = {$vals['ssl']}; \n\n" .
452 // checks if fancy URLs are enabled
453 ($this->fancy ? "\$config['site']['fancy'] = true;\n\n" : '') .
456 "\$config['db']['database'] = {$vals['db_database']};\n\n" .
457 ($this->db['type'] == 'pgsql' ? "\$config['db']['quote_identifiers'] = true;\n\n" : '') .
458 "\$config['db']['type'] = {$vals['db_type']};\n\n" .
460 "// Uncomment below for better performance. Just remember you must run\n" .
461 "// php scripts/checkschema.php whenever your enabled plugins change!\n" .
462 "//\$config['db']['schemacheck'] = 'script';\n\n";
464 // Normalize line endings for Windows servers
465 $cfg = str_replace("\n", PHP_EOL, $cfg);
467 // write configuration file out to install directory
468 $res = file_put_contents(INSTALLDIR . '/config.php', $cfg);
474 * Write the site profile. We do this after creating the initial user
475 * in case the site profile is set to single user. This gets around the
476 * 'chicken-and-egg' problem of the system requiring a valid user for
477 * single user mode, before the intial user is actually created. Yeah,
478 * we should probably do this in smarter way.
480 * @return int res number of bytes written
482 public function writeSiteProfile(): int
484 $vals = $this->phpVals([
485 'site_profile' => $this->siteProfile,
486 'nickname' => $this->adminNick
491 "\$config['site']['profile'] = {$vals['site_profile']};\n";
493 if ($this->siteProfile == "singleuser") {
494 $cfg .= "\$config['singleuser']['nickname'] = {$vals['nickname']};\n\n";
499 // Normalize line endings for Windows servers
500 $cfg = str_replace("\n", PHP_EOL, $cfg);
502 // write configuration file out to install directory
503 $res = file_put_contents(INSTALLDIR . '/config.php', $cfg, FILE_APPEND);
509 * Install schema into the database
511 * @param string $filename location of database schema file
512 * @param DB_common $conn connection to database
514 * @return bool - indicating success or failure
516 public function runDbScript(string $filename, DB_common $conn): bool
518 $sql = trim(file_get_contents(INSTALLDIR . '/db/' . $filename));
519 $stmts = explode(';', $sql);
520 foreach ($stmts as $stmt) {
522 if (!mb_strlen($stmt)) {
526 $res = $conn->query($stmt);
527 } catch (Exception $e) {
528 $error = $e->getMessage();
529 $this->updateStatus("ERROR ($error) for SQL '$stmt'");
537 * Create the initial admin user account.
538 * Side effect: may load portions of GNU social framework.
539 * Side effect: outputs program info
541 public function registerInitialUser(): bool
543 // initalize hostname from install arguments, so it can be used to find
544 // the /etc config file from the commandline installer
545 $server = $this->server;
546 require_once INSTALLDIR . '/lib/common.php';
548 $data = ['nickname' => $this->adminNick,
549 'password' => $this->adminPass,
550 'fullname' => $this->adminNick];
551 if ($this->adminEmail) {
552 $data['email'] = $this->adminEmail;
555 $user = User::register($data, true); // true to skip email sending verification
556 } catch (Exception $e) {
560 // give initial user carte blanche
562 $user->grantRole('owner');
563 $user->grantRole('moderator');
564 $user->grantRole('administrator');
570 * The beef of the installer!
571 * Create database, config file, and admin user.
573 * Prerequisites: validation of input data.
575 * @return bool success
577 public function doInstall(): bool
581 $this->updateStatus("Initializing...");
582 ini_set('display_errors', 1);
583 error_reporting(E_ALL & ~E_STRICT & ~E_NOTICE);
584 if (!defined('GNUSOCIAL')) {
585 define('GNUSOCIAL', true);
587 if (!defined('STATUSNET')) {
588 define('STATUSNET', true);
591 require_once INSTALLDIR . '/lib/framework.php';
592 GNUsocial::initDefaults($this->server, $this->path);
594 if ($this->siteProfile == "singleuser") {
595 // Until we use ['site']['profile']==='singleuser' everywhere
596 $config['singleuser']['enabled'] = true;
600 $this->db = $this->setupDatabase();
602 // database connection failed, do not move on to create config file.
605 } catch (Exception $e) {
606 // Lower-level DB error!
607 $this->updateStatus("Database error: " . $e->getMessage(), true);
611 if (!$this->skipConfig) {
612 // Make sure we can write to the file twice
613 $oldUmask = umask(000);
615 $this->updateStatus("Writing config file...");
616 $res = $this->writeConf();
619 $this->updateStatus("Can't write config file.", true);
624 if (!empty($this->adminNick)) {
625 // Okay, cross fingers and try to register an initial user
626 if ($this->registerInitialUser()) {
628 "An initial user with the administrator role has been created."
632 "Could not create initial user account.",
639 if (!$this->skipConfig) {
640 $this->updateStatus("Setting site profile...");
641 $res = $this->writeSiteProfile();
644 $this->updateStatus("Can't write to config file.", true);
648 // Restore original umask
650 // Set permissions back to something decent
651 chmod(INSTALLDIR . '/config.php', 0644);
654 $scheme = $this->ssl === 'always' ? 'https' : 'http';
655 $link = "{$scheme}://{$this->server}/{$this->path}";
657 $this->updateStatus("GNU social has been installed at $link");
659 '<strong>DONE!</strong> You can visit your <a href="' . htmlspecialchars($link) . '">new GNU social site</a> (log in as "' . htmlspecialchars($this->adminNick) . '"). If this is your first GNU social install, make your experience the best possible by visiting our resource site to join the <a href="https://gnu.io/social/resources/">mailing list or IRC</a>. <a href="' . htmlspecialchars($link) . '/doc/faq">FAQ is found here</a>.'
666 * Output a pre-install-time warning message
667 * @param string $message HTML ok, but should be plaintext-able
668 * @param string $submessage HTML ok, but should be plaintext-able
670 abstract public function warning(string $message, string $submessage = '');
673 * Output an install-time progress message
674 * @param string $status HTML ok, but should be plaintext-able
675 * @param bool $error true if this should be marked as an error condition
677 abstract public function updateStatus(string $status, bool $error = false);