]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - lib/installer.php
Added additional check.
[quix0rs-gnu-social.git] / lib / installer.php
1 <?php
2 // This file is part of GNU social - https://www.gnu.org/software/social
3 //
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.
8 //
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.
13 //
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/>.
16
17 /**
18  * Installation lib
19  *
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
37  */
38
39 abstract class Installer
40 {
41     /** Web site info */
42     public $sitename;
43     public $server;
44     public $path;
45     public $fancy;
46     public $siteProfile;
47     public $ssl;
48     /** DB info */
49     public $host;
50     public $database;
51     public $dbtype;
52     public $username;
53     public $password;
54     public $db;
55     /** Storage info */
56     public $avatarDir;
57     public $fileDir;
58     /** Administrator info */
59     public $adminNick;
60     public $adminPass;
61     public $adminEmail;
62     /** Should we skip writing the configuration file? */
63     public $skipConfig = false;
64
65     public static $dbModules = [
66         'mysql' => [
67             'name' => 'MariaDB 10.3+',
68             'check_module' => 'mysqli',
69             'scheme' => 'mysqli', // DSN prefix for PEAR::DB
70         ],
71         /*'pgsql' => [
72             'name' => 'PostgreSQL',
73             'check_module' => 'pgsql',
74             'scheme' => 'pgsql', // DSN prefix for PEAR::DB
75         ]*/
76     ];
77
78     /**
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
82      * @return bool
83      */
84     private function haveIncludeFile(string $filename): bool
85     {
86         $old = error_reporting(error_reporting() & ~E_WARNING);
87         $ok = include_once($filename);
88         error_reporting($old);
89         return $ok;
90     }
91
92     /**
93      * Check if all is ready for installation
94      *
95      * @return bool
96      */
97     public function checkPrereqs(): bool
98     {
99         $pass = true;
100
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.');
106                 } else {
107                     $this->warning('Config file "config.php" already exists.');
108                 }
109                 $pass = false;
110             }
111         }
112
113         if (version_compare(PHP_VERSION, '7.3.0', '<')) {
114             $this->warning('Require PHP version 7.3.0 or greater.');
115             $pass = false;
116         }
117
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));
123                 $pass = false;
124             }
125         }
126
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'];
132             }
133         }
134
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));
138             $pass = false;
139         }
140
141         // @fixme this check seems to be insufficient with Windows ACLs
142         if (!$this->skipConfig && !is_writable(INSTALLDIR)) {
143             $this->warning(
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)
146             );
147             $pass = false;
148         }
149
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';
160             $fileSubdirs = [
161                 empty($this->avatarDir) ? $default['avatar']['dir'] : $this->avatarDir,
162                 empty($this->fileDir) ? $default['attachments']['dir'] : $this->fileDir
163             ];
164             unset($default);
165             foreach ($fileSubdirs as $fileFullPath) {
166                 if (!file_exists($fileFullPath)) {
167                     $this->warning(
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.'
171                     );
172                     $pass = $pass && mkdir($fileFullPath);
173                 } elseif (!is_dir($fileFullPath)) {
174                     $this->warning(
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.'
177                     );
178                     $pass = false;
179                 } elseif (!is_writable($fileFullPath)) {
180                     $this->warning(
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)
183                     );
184                     $pass = false;
185                 }
186             }
187         }
188         return $pass;
189     }
190
191     /**
192      * Basic validation on the database parameters
193      * Side effects: error output if not valid
194      *
195      * @return bool success
196      */
197     public function validateDb(): bool
198     {
199         $fail = false;
200
201         if (empty($this->host)) {
202             $this->updateStatus("No hostname specified.", true);
203             $fail = true;
204         }
205
206         if (empty($this->database)) {
207             $this->updateStatus("No database specified.", true);
208             $fail = true;
209         }
210
211         if (empty($this->username)) {
212             $this->updateStatus("No username specified.", true);
213             $fail = true;
214         }
215
216         if (empty($this->sitename)) {
217             $this->updateStatus("No sitename specified.", true);
218             $fail = true;
219         }
220
221         return !$fail;
222     }
223
224     /**
225      * Basic validation on the administrator user parameters
226      * Side effects: error output if not valid
227      *
228      * @return bool success
229      */
230     public function validateAdmin(): bool
231     {
232         $fail = false;
233
234         if (empty($this->adminNick)) {
235             $this->updateStatus("No initial user nickname specified.", true);
236             $fail = true;
237         }
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);
241             $fail = true;
242         }
243
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);
250             $fail = true;
251         }
252
253         if (empty($this->adminPass)) {
254             $this->updateStatus("No initial user password specified.", true);
255             $fail = true;
256         }
257
258         return !$fail;
259     }
260
261     /**
262      * Make sure a site profile was selected
263      *
264      * @return bool success
265      */
266     public function validateSiteProfile(): bool
267     {
268         if (empty($this->siteProfile)) {
269             $this->updateStatus("No site profile selected.", true);
270             return false;
271         }
272
273         return true;
274     }
275
276     /**
277      * Set up the database with the appropriate function for the selected type...
278      * Saves database info into $this->db.
279      *
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
282      * @throws Exception
283      */
284     public function setupDatabase()
285     {
286         if ($this->db) {
287             throw new Exception("Bad order of operations: DB already set up.");
288         }
289         $this->updateStatus("Starting installation...");
290
291         if (empty($this->password)) {
292             $auth = '';
293         } else {
294             $auth = ":$this->password";
295         }
296         $scheme = self::$dbModules[$this->dbtype]['scheme'];
297         $dsn = "{$scheme}://{$this->username}{$auth}@{$this->host}/{$this->database}";
298
299         $this->updateStatus("Checking database...");
300         $conn = $this->connectDatabase($dsn);
301
302         if (!$conn instanceof DB_common) {
303             // Is not the right instance
304             throw new Exception('Cannot connect to database: ' . $conn->getMessage());
305         }
306
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));
313                 return false;
314             }
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));
319                 return false;
320             }
321         }
322
323         if (!is_object($conn)) {
324             // No object at all
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());
329         }
330
331         $res = $this->updateStatus("Creating database tables...");
332         if (!$this->createCoreTables($conn)) {
333             $this->updateStatus("Error creating tables.", true);
334             return false;
335         }
336
337         foreach (['sms_carrier' => 'SMS carrier',
338                      'notice_source' => 'notice source',
339                      'foreign_services' => 'foreign service']
340                  as $scr => $name) {
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);
345                 return false;
346             }
347         }
348
349         $db = ['type' => $this->dbtype, 'database' => $dsn];
350         return $db;
351     }
352
353     /**
354      * Open a connection to the database.
355      *
356      * @param string $dsn
357      * @return DB|DB_Error
358      */
359     public function connectDatabase(string $dsn)
360     {
361         global $_DB;
362         return $_DB->connect($dsn);
363     }
364
365     /**
366      * Create core tables on the given database connection.
367      *
368      * @param DB_common $conn
369      * @return bool
370      */
371     public function createCoreTables(DB_common $conn): bool
372     {
373         $schema = Schema::get($conn);
374         $tableDefs = $this->getCoreSchema();
375         foreach ($tableDefs as $name => $def) {
376             if (defined('DEBUG_INSTALLER')) {
377                 echo " $name ";
378             }
379             $schema->ensureTable($name, $def);
380         }
381         return true;
382     }
383
384     /**
385      * Fetch the core table schema definitions.
386      *
387      * @return array of table names => table def arrays
388      */
389     public function getCoreSchema(): array
390     {
391         $schema = [];
392         include INSTALLDIR . '/db/core.php';
393         return $schema;
394     }
395
396     /**
397      * Return a parseable PHP literal for the given value.
398      * This will include quotes for strings, etc.
399      *
400      * @param mixed $val
401      * @return string
402      */
403     public function phpVal($val): string
404     {
405         return var_export($val, true);
406     }
407
408     /**
409      * Return an array of parseable PHP literal for the given values.
410      * These will include quotes for strings, etc.
411      *
412      * @param mixed $map
413      * @return array
414      */
415     public function phpVals($map): array
416     {
417         return array_map([$this, 'phpVal'], $map);
418     }
419
420     /**
421      * Write a stock configuration file.
422      *
423      * @return bool success
424      *
425      * @fixme escape variables in output in case we have funny chars, apostrophes etc
426      */
427     public function writeConf(): bool
428     {
429         $vals = $this->phpVals([
430             'sitename' => $this->sitename,
431             'server' => $this->server,
432             'path' => $this->path,
433             'ssl' => in_array($this->ssl, ['never', 'always'])
434                 ? $this->ssl
435                 : 'never',
436             'db_database' => $this->db['database'],
437             'db_type' => $this->db['type']
438         ]);
439
440         // assemble configuration file in a string
441         $cfg = "<?php\n" .
442             "if (!defined('GNUSOCIAL')) { exit(1); }\n\n" .
443
444             // site name
445             "\$config['site']['name'] = {$vals['sitename']};\n\n" .
446
447             // site location
448             "\$config['site']['server'] = {$vals['server']};\n" .
449             "\$config['site']['path'] = {$vals['path']}; \n\n" .
450             "\$config['site']['ssl'] = {$vals['ssl']}; \n\n" .
451
452             // checks if fancy URLs are enabled
453             ($this->fancy ? "\$config['site']['fancy'] = true;\n\n" : '') .
454
455             // database
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" .
459
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";
463
464         // Normalize line endings for Windows servers
465         $cfg = str_replace("\n", PHP_EOL, $cfg);
466
467         // write configuration file out to install directory
468         $res = file_put_contents(INSTALLDIR . '/config.php', $cfg);
469
470         return $res;
471     }
472
473     /**
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.
479      *
480      * @return int res number of bytes written
481      */
482     public function writeSiteProfile(): int
483     {
484         $vals = $this->phpVals([
485             'site_profile' => $this->siteProfile,
486             'nickname' => $this->adminNick
487         ]);
488
489         $cfg =
490             // site profile
491             "\$config['site']['profile'] = {$vals['site_profile']};\n";
492
493         if ($this->siteProfile == "singleuser") {
494             $cfg .= "\$config['singleuser']['nickname'] = {$vals['nickname']};\n\n";
495         } else {
496             $cfg .= "\n";
497         }
498
499         // Normalize line endings for Windows servers
500         $cfg = str_replace("\n", PHP_EOL, $cfg);
501
502         // write configuration file out to install directory
503         $res = file_put_contents(INSTALLDIR . '/config.php', $cfg, FILE_APPEND);
504
505         return $res;
506     }
507
508     /**
509      * Install schema into the database
510      *
511      * @param string $filename location of database schema file
512      * @param DB_common $conn connection to database
513      *
514      * @return bool - indicating success or failure
515      */
516     public function runDbScript(string $filename, DB_common $conn): bool
517     {
518         $sql = trim(file_get_contents(INSTALLDIR . '/db/' . $filename));
519         $stmts = explode(';', $sql);
520         foreach ($stmts as $stmt) {
521             $stmt = trim($stmt);
522             if (!mb_strlen($stmt)) {
523                 continue;
524             }
525             try {
526                 $res = $conn->query($stmt);
527             } catch (Exception $e) {
528                 $error = $e->getMessage();
529                 $this->updateStatus("ERROR ($error) for SQL '$stmt'");
530                 return false;
531             }
532         }
533         return true;
534     }
535
536     /**
537      * Create the initial admin user account.
538      * Side effect: may load portions of GNU social framework.
539      * Side effect: outputs program info
540      */
541     public function registerInitialUser(): bool
542     {
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';
547
548         $data = ['nickname' => $this->adminNick,
549             'password' => $this->adminPass,
550             'fullname' => $this->adminNick];
551         if ($this->adminEmail) {
552             $data['email'] = $this->adminEmail;
553         }
554         try {
555             $user = User::register($data, true);    // true to skip email sending verification
556         } catch (Exception $e) {
557             return false;
558         }
559
560         // give initial user carte blanche
561
562         $user->grantRole('owner');
563         $user->grantRole('moderator');
564         $user->grantRole('administrator');
565
566         return true;
567     }
568
569     /**
570      * The beef of the installer!
571      * Create database, config file, and admin user.
572      *
573      * Prerequisites: validation of input data.
574      *
575      * @return bool success
576      */
577     public function doInstall(): bool
578     {
579         global $config;
580
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);
586         }
587         if (!defined('STATUSNET')) {
588             define('STATUSNET', true);
589         }
590
591         require_once INSTALLDIR . '/lib/framework.php';
592         GNUsocial::initDefaults($this->server, $this->path);
593
594         if ($this->siteProfile == "singleuser") {
595             // Until we use ['site']['profile']==='singleuser' everywhere
596             $config['singleuser']['enabled'] = true;
597         }
598
599         try {
600             $this->db = $this->setupDatabase();
601             if (!$this->db) {
602                 // database connection failed, do not move on to create config file.
603                 return false;
604             }
605         } catch (Exception $e) {
606             // Lower-level DB error!
607             $this->updateStatus("Database error: " . $e->getMessage(), true);
608             return false;
609         }
610
611         if (!$this->skipConfig) {
612             // Make sure we can write to the file twice
613             $oldUmask = umask(000);
614
615             $this->updateStatus("Writing config file...");
616             $res = $this->writeConf();
617
618             if (!$res) {
619                 $this->updateStatus("Can't write config file.", true);
620                 return false;
621             }
622         }
623
624         if (!empty($this->adminNick)) {
625             // Okay, cross fingers and try to register an initial user
626             if ($this->registerInitialUser()) {
627                 $this->updateStatus(
628                     "An initial user with the administrator role has been created."
629                 );
630             } else {
631                 $this->updateStatus(
632                     "Could not create initial user account.",
633                     true
634                 );
635                 return false;
636             }
637         }
638
639         if (!$this->skipConfig) {
640             $this->updateStatus("Setting site profile...");
641             $res = $this->writeSiteProfile();
642
643             if (!$res) {
644                 $this->updateStatus("Can't write to config file.", true);
645                 return false;
646             }
647
648             // Restore original umask
649             umask($oldUmask);
650             // Set permissions back to something decent
651             chmod(INSTALLDIR . '/config.php', 0644);
652         }
653
654         $scheme = $this->ssl === 'always' ? 'https' : 'http';
655         $link = "{$scheme}://{$this->server}/{$this->path}";
656
657         $this->updateStatus("GNU social has been installed at $link");
658         $this->updateStatus(
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>.'
660         );
661
662         return true;
663     }
664
665     /**
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
669      */
670     abstract public function warning(string $message, string $submessage = '');
671
672     /**
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
676      */
677     abstract public function updateStatus(string $status, bool $error = false);
678 }