git.gnu.io has moved to IP address 209.51.188.249 -- please double check where you are logging in.

installer.php 22.2 KB
Newer Older
1 2 3 4
<?php

/**
 * StatusNet - the distributed open-source microblogging tool
5
 * Copyright (C) 2009-2010, StatusNet, Inc.
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
 *
 * 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 <http://www.gnu.org/licenses/>.
 *
 * @category Installation
 * @package  Installation
 *
 * @author   Adrian Lang <mail@adrianlang.de>
 * @author   Brenda Wallace <shiny@cpan.org>
 * @author   Brett Taylor <brett@webfroot.co.nz>
 * @author   Brion Vibber <brion@pobox.com>
 * @author   CiaranG <ciaran@ciarang.com>
 * @author   Craig Andrews <candrews@integralblue.com>
 * @author   Eric Helgeson <helfire@Erics-MBP.local>
 * @author   Evan Prodromou <evan@status.net>
31
 * @author   Mikael Nordfeldth <mmn@hethane.se>
32 33 34 35
 * @author   Robin Millette <millette@controlyourself.ca>
 * @author   Sarven Capadisli <csarven@status.net>
 * @author   Tom Adams <tom@holizz.com>
 * @author   Zach Copley <zach@status.net>
36
 * @copyright 2009-2010 StatusNet, Inc http://status.net
mmn's avatar
mmn committed
37
 * @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
38
 * @license  GNU Affero General Public License http://www.gnu.org/licenses/
39
 * @version  1.0.x
40 41 42 43 44 45
 * @link     http://status.net
 */

abstract class Installer
{
    /** Web site info */
46
    public $sitename, $server, $path, $fancy, $siteProfile, $ssl;
47
    /** DB info */
48
    public $host, $database, $dbtype, $username, $password, $db;
49
    /** Administrator info */
50
    public $adminNick, $adminPass, $adminEmail;
51 52 53 54 55
    /** Should we skip writing the configuration file? */
    public $skipConfig = false;

    public static $dbModules = array(
        'mysql' => array(
mmn's avatar
mmn committed
56
            'name' => 'MariaDB (or MySQL 5.5+)',
57
            'check_module' => 'mysqli',
58
            'scheme' => 'mysqli', // DSN prefix for PEAR::DB
59
        ),
mmn's avatar
mmn committed
60
/*        'pgsql' => array(
61 62
            'name' => 'PostgreSQL',
            'check_module' => 'pgsql',
63
            'scheme' => 'pgsql', // DSN prefix for PEAR::DB
mmn's avatar
mmn committed
64
        ),*/
65 66 67 68 69 70 71 72 73 74 75 76
    );

    /**
     * Attempt to include a PHP file and report if it worked, while
     * suppressing the annoying warning messages on failure.
     */
    private function haveIncludeFile($filename) {
        $old = error_reporting(error_reporting() & ~E_WARNING);
        $ok = include_once($filename);
        error_reporting($old);
        return $ok;
    }
Zach Copley's avatar
Zach Copley committed
77

78 79 80 81 82 83 84 85 86
    /**
     * Check if all is ready for installation
     *
     * @return void
     */
    function checkPrereqs()
    {
        $pass = true;

87
        $config = INSTALLDIR.'/config.php';
88
        if (!$this->skipConfig && file_exists($config)) {
89
            if (!is_writable($config) || filesize($config) > 0) {
90 91 92 93 94
                if (filesize($config) == 0) {
                    $this->warning('Config file "config.php" already exists and is empty, but is not writable.');
                } else {
                    $this->warning('Config file "config.php" already exists.');
                }
95 96
                $pass = false;
            }
97 98
        }

99 100
        if (version_compare(PHP_VERSION, '5.5.0', '<')) {
            $this->warning('Require PHP version 5.5.0 or greater.');
101 102 103
            $pass = false;
        }

104
        $reqs = array('gd', 'curl', 'intl', 'json',
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127
                      'xmlwriter', 'mbstring', 'xml', 'dom', 'simplexml');

        foreach ($reqs as $req) {
            if (!$this->checkExtension($req)) {
                $this->warning(sprintf('Cannot load required extension: <code>%s</code>', $req));
                $pass = false;
            }
        }

        // Make sure we have at least one database module available
        $missingExtensions = array();
        foreach (self::$dbModules as $type => $info) {
            if (!$this->checkExtension($info['check_module'])) {
                $missingExtensions[] = $info['check_module'];
            }
        }

        if (count($missingExtensions) == count(self::$dbModules)) {
            $req = implode(', ', $missingExtensions);
            $this->warning(sprintf('Cannot find a database extension. You need at least one of %s.', $req));
            $pass = false;
        }

128
        // @fixme this check seems to be insufficient with Windows ACLs
129
        if (!$this->skipConfig && !is_writable(INSTALLDIR)) {
130 131 132 133 134 135
            $this->warning(sprintf('Cannot write config file to: <code>%s</code></p>', INSTALLDIR),
                           sprintf('On your server, try this command: <code>chmod a+w %s</code>', INSTALLDIR));
            $pass = false;
        }

        // Check the subdirs used for file uploads
136 137 138
        // TODO get another flag for this --skipFileSubdirCreation
        if (!$this->skipConfig) {
            $fileSubdirs = array($this->avatarDir, $this->fileDir);
139
        foreach ($fileSubdirs as $fileSubdir) {
140 141 142 143 144 145 146 147
            $fileFullPath = INSTALLDIR."/$fileSubdir";
            if (!file_exists($fileFullPath)) {
                $pass = $pass && mkdir($fileFullPath);
            } elseif (!is_dir($fileFullPath)) {
                $this->warning(sprintf('GNU social expected a directory but found something else on this path: %s', $fileFullPath),
                               'Either make sure it goes to a directory or remove it and a directory will be created.');
                $pass = false;
            } elseif (!is_writable($fileFullPath)) {
148 149 150 151 152
                $this->warning(sprintf('Cannot write to %s directory: <code>%s</code>', $fileSubdir, $fileFullPath),
                               sprintf('On your server, try this command: <code>chmod a+w %s</code>', $fileFullPath));
                $pass = false;
            }
        }
153
        }
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183
        return $pass;
    }

    /**
     * Checks if a php extension is both installed and loaded
     *
     * @param string $name of extension to check
     *
     * @return boolean whether extension is installed and loaded
     */
    function checkExtension($name)
    {
        if (extension_loaded($name)) {
            return true;
        } elseif (function_exists('dl') && ini_get('enable_dl') && !ini_get('safe_mode')) {
            // dl will throw a fatal error if it's disabled or we're in safe mode.
            // More fun, it may not even exist under some SAPIs in 5.3.0 or later...
            $soname = $name . '.' . PHP_SHLIB_SUFFIX;
            if (PHP_SHLIB_SUFFIX == 'dll') {
                $soname = "php_" . $soname;
            }
            return @dl($soname);
        } else {
            return false;
        }
    }

    /**
     * Basic validation on the database paramters
     * Side effects: error output if not valid
Zach Copley's avatar
Zach Copley committed
184
     *
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216
     * @return boolean success
     */
    function validateDb()
    {
        $fail = false;

        if (empty($this->host)) {
            $this->updateStatus("No hostname specified.", true);
            $fail = true;
        }

        if (empty($this->database)) {
            $this->updateStatus("No database specified.", true);
            $fail = true;
        }

        if (empty($this->username)) {
            $this->updateStatus("No username specified.", true);
            $fail = true;
        }

        if (empty($this->sitename)) {
            $this->updateStatus("No sitename specified.", true);
            $fail = true;
        }

        return !$fail;
    }

    /**
     * Basic validation on the administrator user paramters
     * Side effects: error output if not valid
Zach Copley's avatar
Zach Copley committed
217
     *
218 219 220 221 222 223 224
     * @return boolean success
     */
    function validateAdmin()
    {
        $fail = false;

        if (empty($this->adminNick)) {
mmn's avatar
mmn committed
225
            $this->updateStatus("No initial user nickname specified.", true);
226 227 228 229 230 231 232
            $fail = true;
        }
        if ($this->adminNick && !preg_match('/^[0-9a-z]{1,64}$/', $this->adminNick)) {
            $this->updateStatus('The user nickname "' . htmlspecialchars($this->adminNick) .
                         '" is invalid; should be plain letters and numbers no longer than 64 characters.', true);
            $fail = true;
        }
233
        // @fixme hardcoded list; should use Nickname::isValid()
234
        // if/when it's safe to have loaded the infrastructure here
235
        $blacklist = array('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');
236 237 238 239 240 241 242
        if (in_array($this->adminNick, $blacklist)) {
            $this->updateStatus('The user nickname "' . htmlspecialchars($this->adminNick) .
                         '" is reserved.', true);
            $fail = true;
        }

        if (empty($this->adminPass)) {
mmn's avatar
mmn committed
243
            $this->updateStatus("No initial user password specified.", true);
244 245 246 247 248 249
            $fail = true;
        }

        return !$fail;
    }

Zach Copley's avatar
Zach Copley committed
250 251 252 253 254 255 256
    /**
     * Make sure a site profile was selected
     *
     * @return type boolean success
     */
    function validateSiteProfile()
    {
257
        if (empty($this->siteProfile))  {
Zach Copley's avatar
Zach Copley committed
258
            $this->updateStatus("No site profile selected.", true);
259
            return false;
Zach Copley's avatar
Zach Copley committed
260 261
        }

262
        return true;
Zach Copley's avatar
Zach Copley committed
263 264
    }

265 266 267
    /**
     * Set up the database with the appropriate function for the selected type...
     * Saves database info into $this->db.
Zach Copley's avatar
Zach Copley committed
268
     *
269
     * @fixme escape things in the connection string in case we have a funny pass etc
270 271 272 273 274 275 276 277 278
     * @return mixed array of database connection params on success, false on failure
     */
    function setupDatabase()
    {
        if ($this->db) {
            throw new Exception("Bad order of operations: DB already set up.");
        }
        $this->updateStatus("Starting installation...");

279
        if (empty($this->password)) {
280 281 282
            $auth = '';
        } else {
            $auth = ":$this->password";
283
        }
284
        $scheme = self::$dbModules[$this->dbtype]['scheme'];
285
        $dsn = "{$scheme}://{$this->username}{$auth}@{$this->host}/{$this->database}";
286

287 288
        $this->updateStatus("Checking database...");
        $conn = $this->connectDatabase($dsn);
289

290 291 292 293 294
        if (!$conn instanceof DB_common) {
            // Is not the right instance
            throw new Exception('Cannot connect to database: ' . $conn->getMessage());
        }

295 296 297 298
        // ensure database encoding is UTF8
        if ($this->dbtype == 'mysql') {
            // @fixme utf8m4 support for mysql 5.5?
            // Force the comms charset to utf8 for sanity
299 300
            // This doesn't currently work. :P
            //$conn->executes('set names utf8');
301 302 303
        } else if ($this->dbtype == 'pgsql') {
            $record = $conn->getRow('SHOW server_encoding');
            if ($record->server_encoding != 'UTF8') {
mmn's avatar
mmn committed
304
                $this->updateStatus("GNU social requires UTF8 character encoding. Your database is ". htmlentities($record->server_encoding));
305 306 307
                return false;
            }
        }
308

309 310 311
        $res = $this->updateStatus("Creating database tables...");
        if (!$this->createCoreTables($conn)) {
            $this->updateStatus("Error creating tables.", true);
312 313
            return false;
        }
314

315 316 317 318 319
        foreach (array('sms_carrier' => 'SMS carrier',
                    'notice_source' => 'notice source',
                    'foreign_services' => 'foreign service')
              as $scr => $name) {
            $this->updateStatus(sprintf("Adding %s data to database...", $name));
320
            $res = $this->runDbScript($scr.'.sql', $conn);
321
            if ($res === false) {
Evan Prodromou's avatar
Evan Prodromou committed
322
                $this->updateStatus(sprintf("Can't run %s script.", $name), true);
323 324 325 326
                return false;
            }
        }

327
        $db = array('type' => $this->dbtype, 'database' => $dsn);
328 329 330 331
        return $db;
    }

    /**
332 333 334
     * Open a connection to the database.
     *
     * @param <type> $dsn
Zach Copley's avatar
Zach Copley committed
335
     * @return <type>
336
     */
337
    function connectDatabase($dsn)
338
    {
339 340
        global $_DB;
        return $_DB->connect($dsn);
341
    }
342

343 344 345 346 347 348 349 350 351 352
    /**
     * Create core tables on the given database connection.
     *
     * @param DB_common $conn
     */
    function createCoreTables(DB_common $conn)
    {
        $schema = Schema::get($conn);
        $tableDefs = $this->getCoreSchema();
        foreach ($tableDefs as $name => $def) {
353 354 355
            if (defined('DEBUG_INSTALLER')) {
                echo " $name ";
            }
356
            $schema->ensureTable($name, $def);
357
        }
358
        return true;
359
    }
360

361 362 363 364 365 366 367 368 369 370
    /**
     * Fetch the core table schema definitions.
     *
     * @return array of table names => table def arrays
     */
    function getCoreSchema()
    {
        $schema = array();
        include INSTALLDIR . '/db/core.php';
        return $schema;
371 372
    }

373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396
    /**
     * Return a parseable PHP literal for the given value.
     * This will include quotes for strings, etc.
     *
     * @param mixed $val
     * @return string
     */
    function phpVal($val)
    {
        return var_export($val, true);
    }

    /**
     * Return an array of parseable PHP literal for the given values.
     * These will include quotes for strings, etc.
     *
     * @param mixed $val
     * @return array
     */
    function phpVals($map)
    {
        return array_map(array($this, 'phpVal'), $map);
    }

397 398 399 400
    /**
     * Write a stock configuration file.
     *
     * @return boolean success
Zach Copley's avatar
Zach Copley committed
401
     *
402 403 404 405
     * @fixme escape variables in output in case we have funny chars, apostrophes etc
     */
    function writeConf()
    {
406 407 408 409
        $vals = $this->phpVals(array(
            'sitename' => $this->sitename,
            'server' => $this->server,
            'path' => $this->path,
mmn's avatar
mmn committed
410
            'ssl' => in_array($this->ssl, array('never', 'always'))
411 412
                     ? $this->ssl
                     : 'never',
413
            'db_database' => $this->db['database'],
414
            'db_type' => $this->db['type']
415 416
        ));

417 418
        // assemble configuration file in a string
        $cfg =  "<?php\n".
419
                "if (!defined('GNUSOCIAL')) { exit(1); }\n\n".
420 421

                // site name
422
                "\$config['site']['name'] = {$vals['sitename']};\n\n".
423 424

                // site location
425 426
                "\$config['site']['server'] = {$vals['server']};\n".
                "\$config['site']['path'] = {$vals['path']}; \n\n".
427
                "\$config['site']['ssl'] = {$vals['ssl']}; \n\n".
428 429 430 431 432

                // checks if fancy URLs are enabled
                ($this->fancy ? "\$config['site']['fancy'] = true;\n\n":'').

                // database
433
                "\$config['db']['database'] = {$vals['db_database']};\n\n".
434
                ($this->db['type'] == 'pgsql' ? "\$config['db']['quote_identifiers'] = true;\n\n":'').
435 436 437 438 439
                "\$config['db']['type'] = {$vals['db_type']};\n\n".

                "// Uncomment below for better performance. Just remember you must run\n".
                "// php scripts/checkschema.php whenever your enabled plugins change!\n".
                "//\$config['db']['schemacheck'] = 'script';\n\n";
440 441 442

        // Normalize line endings for Windows servers
        $cfg = str_replace("\n", PHP_EOL, $cfg);
Zach Copley's avatar
Zach Copley committed
443

444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468
        // write configuration file out to install directory
        $res = file_put_contents(INSTALLDIR.'/config.php', $cfg);

        return $res;
    }

    /**
     * Write the site profile. We do this after creating the initial user
     * in case the site profile is set to single user. This gets around the
     * 'chicken-and-egg' problem of the system requiring a valid user for
     * single user mode, before the intial user is actually created. Yeah,
     * we should probably do this in smarter way.
     *
     * @return int res number of bytes written
     */
    function writeSiteProfile()
    {
        $vals = $this->phpVals(array(
            'site_profile' => $this->siteProfile,
            'nickname' => $this->adminNick
        ));

        $cfg =
        // site profile
        "\$config['site']['profile'] = {$vals['site_profile']};\n";
469 470 471 472 473 474

        if ($this->siteProfile == "singleuser") {
            $cfg .= "\$config['singleuser']['nickname'] = {$vals['nickname']};\n\n";
        } else {
            $cfg .= "\n";
        }
475 476 477 478

        // Normalize line endings for Windows servers
        $cfg = str_replace("\n", PHP_EOL, $cfg);

479
        // write configuration file out to install directory
480
        $res = file_put_contents(INSTALLDIR.'/config.php', $cfg, FILE_APPEND);
481 482 483 484 485 486 487

        return $res;
    }

    /**
     * Install schema into the database
     *
488 489
     * @param string    $filename location of database schema file
     * @param DB_common $conn     connection to database
490 491 492
     *
     * @return boolean - indicating success or failure
     */
493
    function runDbScript($filename, DB_common $conn)
494 495 496 497 498 499 500 501
    {
        $sql = trim(file_get_contents(INSTALLDIR . '/db/' . $filename));
        $stmts = explode(';', $sql);
        foreach ($stmts as $stmt) {
            $stmt = trim($stmt);
            if (!mb_strlen($stmt)) {
                continue;
            }
502 503 504 505
            try {
                $res = $conn->simpleQuery($stmt);
            } catch (Exception $e) {
                $error = $e->getMessage();
506
                $this->updateStatus("ERROR ($error) for SQL '$stmt'");
507
                return false;
508 509 510 511 512 513 514
            }
        }
        return true;
    }

    /**
     * Create the initial admin user account.
mmn's avatar
mmn committed
515
     * Side effect: may load portions of GNU social framework.
516 517 518 519
     * Side effect: outputs program info
     */
    function registerInitialUser()
    {
520 521 522
        // initalize hostname from install arguments, so it can be used to find
        // the /etc config file from the commandline installer
        $server = $this->server;
523 524 525 526 527 528 529 530
        require_once INSTALLDIR . '/lib/common.php';

        $data = array('nickname' => $this->adminNick,
                      'password' => $this->adminPass,
                      'fullname' => $this->adminNick);
        if ($this->adminEmail) {
            $data['email'] = $this->adminEmail;
        }
531
        try {
532
            $user = User::register($data, true);    // true to skip email sending verification
533
        } catch (Exception $e) {
534 535 536 537 538 539 540 541
            return false;
        }

        // give initial user carte blanche

        $user->grantRole('owner');
        $user->grantRole('moderator');
        $user->grantRole('administrator');
Zach Copley's avatar
Zach Copley committed
542

543 544 545 546 547 548
        return true;
    }

    /**
     * The beef of the installer!
     * Create database, config file, and admin user.
Zach Copley's avatar
Zach Copley committed
549
     *
550
     * Prerequisites: validation of input data.
Zach Copley's avatar
Zach Copley committed
551
     *
552 553 554 555
     * @return boolean success
     */
    function doInstall()
    {
556 557
        global $config;

558 559
        $this->updateStatus("Initializing...");
        ini_set('display_errors', 1);
560
        error_reporting(E_ALL & ~E_STRICT & ~E_NOTICE);
561 562 563
        if (!defined('GNUSOCIAL')) {
            define('GNUSOCIAL', true);
        }
564
        if (!defined('STATUSNET')) {
565
            define('STATUSNET', true);
566
        }
567

568
        require_once INSTALLDIR . '/lib/framework.php';
mmn's avatar
mmn committed
569
        GNUsocial::initDefaults($this->server, $this->path);
570

571 572 573 574 575
        if ($this->siteProfile == "singleuser") {
            // Until we use ['site']['profile']==='singleuser' everywhere
            $config['singleuser']['enabled'] = true;
        }

576 577 578 579 580 581 582 583 584
        try {
            $this->db = $this->setupDatabase();
            if (!$this->db) {
                // database connection failed, do not move on to create config file.
                return false;
            }
        } catch (Exception $e) {
            // Lower-level DB error!
            $this->updateStatus("Database error: " . $e->getMessage(), true);
585 586 587
            return false;
        }

588
        if (!$this->skipConfig) {
589 590 591
        // Make sure we can write to the file twice
        $oldUmask = umask(000); 

592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608
            $this->updateStatus("Writing config file...");
            $res = $this->writeConf();

            if (!$res) {
                $this->updateStatus("Can't write config file.", true);
                return false;
            }
        }

        if (!empty($this->adminNick)) {
            // Okay, cross fingers and try to register an initial user
            if ($this->registerInitialUser()) {
                $this->updateStatus(
                    "An initial user with the administrator role has been created."
                );
            } else {
                $this->updateStatus(
mmn's avatar
mmn committed
609
                    "Could not create initial user account.",
610 611 612 613 614 615
                    true
                );
                return false;
            }
        }

616 617 618 619 620 621 622 623 624 625 626 627 628
        if (!$this->skipConfig) {
            $this->updateStatus("Setting site profile...");
            $res = $this->writeSiteProfile();

            if (!$res) {
                $this->updateStatus("Can't write to config file.", true);
                return false;
            }

        // Restore original umask
        umask($oldUmask);
        // Set permissions back to something decent
        chmod(INSTALLDIR.'/config.php', 0644);
629
        }
630
        
631 632
        $scheme = $this->ssl === 'always' ? 'https' : 'http';
        $link = "{$scheme}://{$this->server}/{$this->path}";
633

mmn's avatar
mmn committed
634
        $this->updateStatus("GNU social has been installed at $link");
635
        $this->updateStatus(
636
            '<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>.'
637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656
        );

        return true;
    }

    /**
     * Output a pre-install-time warning message
     * @param string $message HTML ok, but should be plaintext-able
     * @param string $submessage HTML ok, but should be plaintext-able
     */
    abstract function warning($message, $submessage='');

    /**
     * Output an install-time progress message
     * @param string $message HTML ok, but should be plaintext-able
     * @param boolean $error true if this should be marked as an error condition
     */
    abstract function updateStatus($status, $error=false);

}