]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - lib/installer.php
Merge branch 'master' into 1.1.x
[quix0rs-gnu-social.git] / lib / installer.php
1 <?php
2
3 /**
4  * StatusNet - the distributed open-source microblogging tool
5  * Copyright (C) 2009-2010, StatusNet, Inc.
6  *
7  * This program is free software: you can redistribute it and/or modify
8  * it under the terms of the GNU Affero General Public License as published by
9  * the Free Software Foundation, either version 3 of the License, or
10  * (at your option) any later version.
11  *
12  * This program is distributed in the hope that it will be useful,
13  * but WITHOUT ANY WARRANTY; without even the implied warranty of
14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15  * GNU Affero General Public License for more details.
16  *
17  * You should have received a copy of the GNU Affero General Public License
18  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
19  *
20  * @category Installation
21  * @package  Installation
22  *
23  * @author   Adrian Lang <mail@adrianlang.de>
24  * @author   Brenda Wallace <shiny@cpan.org>
25  * @author   Brett Taylor <brett@webfroot.co.nz>
26  * @author   Brion Vibber <brion@pobox.com>
27  * @author   CiaranG <ciaran@ciarang.com>
28  * @author   Craig Andrews <candrews@integralblue.com>
29  * @author   Eric Helgeson <helfire@Erics-MBP.local>
30  * @author   Evan Prodromou <evan@status.net>
31  * @author   Robin Millette <millette@controlyourself.ca>
32  * @author   Sarven Capadisli <csarven@status.net>
33  * @author   Tom Adams <tom@holizz.com>
34  * @author   Zach Copley <zach@status.net>
35  * @copyright 2009-2010 StatusNet, Inc http://status.net
36  * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org
37  * @license  GNU Affero General Public License http://www.gnu.org/licenses/
38  * @version  1.0.x
39  * @link     http://status.net
40  */
41
42 abstract class Installer
43 {
44     /** Web site info */
45     public $sitename, $server, $path, $fancy, $siteProfile;
46     /** DB info */
47     public $host, $database, $dbtype, $username, $password, $db;
48     /** Administrator info */
49     public $adminNick, $adminPass, $adminEmail, $adminUpdates;
50     /** Should we skip writing the configuration file? */
51     public $skipConfig = false;
52
53     public static $dbModules = array(
54         'mysql' => array(
55             'name' => 'MySQL',
56             'check_module' => 'mysqli',
57             'scheme' => 'mysqli', // DSN prefix for PEAR::DB
58         ),
59         'pgsql' => array(
60             'name' => 'PostgreSQL',
61             'check_module' => 'pgsql',
62             'scheme' => 'pgsql', // DSN prefix for PEAR::DB
63         ),
64     );
65
66     /**
67      * Attempt to include a PHP file and report if it worked, while
68      * suppressing the annoying warning messages on failure.
69      */
70     private function haveIncludeFile($filename) {
71         $old = error_reporting(error_reporting() & ~E_WARNING);
72         $ok = include_once($filename);
73         error_reporting($old);
74         return $ok;
75     }
76
77     /**
78      * Check if all is ready for installation
79      *
80      * @return void
81      */
82     function checkPrereqs()
83     {
84         $pass = true;
85
86         $config = INSTALLDIR.'/config.php';
87         if (file_exists($config)) {
88             if (!is_writable($config) || filesize($config) > 0) {
89                 if (filesize($config) == 0) {
90                     $this->warning('Config file "config.php" already exists and is empty, but is not writable.');
91                 } else {
92                     $this->warning('Config file "config.php" already exists.');
93                 }
94                 $pass = false;
95             }
96         }
97
98         if (version_compare(PHP_VERSION, '5.2.3', '<')) {
99             $this->warning('Require PHP version 5.2.3 or greater.');
100             $pass = false;
101         }
102
103         // Look for known library bugs
104         $str = "abcdefghijklmnopqrstuvwxyz";
105         $replaced = preg_replace('/[\p{Cc}\p{Cs}]/u', '*', $str);
106         if ($str != $replaced) {
107             $this->warning('PHP is linked to a version of the PCRE library ' .
108                            'that does not support Unicode properties. ' .
109                            'If you are running Red Hat Enterprise Linux / ' .
110                            'CentOS 5.4 or earlier, see <a href="' .
111                            'http://status.net/wiki/Red_Hat_Enterprise_Linux#PCRE_library' .
112                            '">our documentation page</a> on fixing this.');
113             $pass = false;
114         }
115
116         $reqs = array('gd', 'curl',
117                       'xmlwriter', 'mbstring', 'xml', 'dom', 'simplexml');
118
119         foreach ($reqs as $req) {
120             if (!$this->checkExtension($req)) {
121                 $this->warning(sprintf('Cannot load required extension: <code>%s</code>', $req));
122                 $pass = false;
123             }
124         }
125
126         // Make sure we have at least one database module available
127         $missingExtensions = array();
128         foreach (self::$dbModules as $type => $info) {
129             if (!$this->checkExtension($info['check_module'])) {
130                 $missingExtensions[] = $info['check_module'];
131             }
132         }
133
134         if (count($missingExtensions) == count(self::$dbModules)) {
135             $req = implode(', ', $missingExtensions);
136             $this->warning(sprintf('Cannot find a database extension. You need at least one of %s.', $req));
137             $pass = false;
138         }
139
140         // @fixme this check seems to be insufficient with Windows ACLs
141         if (!is_writable(INSTALLDIR)) {
142             $this->warning(sprintf('Cannot write config file to: <code>%s</code></p>', INSTALLDIR),
143                            sprintf('On your server, try this command: <code>chmod a+w %s</code>', INSTALLDIR));
144             $pass = false;
145         }
146
147         // Check the subdirs used for file uploads
148         $fileSubdirs = array('avatar', 'background', 'file');
149         foreach ($fileSubdirs as $fileSubdir) {
150             $fileFullPath = INSTALLDIR."/$fileSubdir/";
151             if (!is_writable($fileFullPath)) {
152                 $this->warning(sprintf('Cannot write to %s directory: <code>%s</code>', $fileSubdir, $fileFullPath),
153                                sprintf('On your server, try this command: <code>chmod a+w %s</code>', $fileFullPath));
154                 $pass = false;
155             }
156         }
157
158         return $pass;
159     }
160
161     /**
162      * Checks if a php extension is both installed and loaded
163      *
164      * @param string $name of extension to check
165      *
166      * @return boolean whether extension is installed and loaded
167      */
168     function checkExtension($name)
169     {
170         if (extension_loaded($name)) {
171             return true;
172         } elseif (function_exists('dl') && ini_get('enable_dl') && !ini_get('safe_mode')) {
173             // dl will throw a fatal error if it's disabled or we're in safe mode.
174             // More fun, it may not even exist under some SAPIs in 5.3.0 or later...
175             $soname = $name . '.' . PHP_SHLIB_SUFFIX;
176             if (PHP_SHLIB_SUFFIX == 'dll') {
177                 $soname = "php_" . $soname;
178             }
179             return @dl($soname);
180         } else {
181             return false;
182         }
183     }
184
185     /**
186      * Basic validation on the database paramters
187      * Side effects: error output if not valid
188      *
189      * @return boolean success
190      */
191     function validateDb()
192     {
193         $fail = false;
194
195         if (empty($this->host)) {
196             $this->updateStatus("No hostname specified.", true);
197             $fail = true;
198         }
199
200         if (empty($this->database)) {
201             $this->updateStatus("No database specified.", true);
202             $fail = true;
203         }
204
205         if (empty($this->username)) {
206             $this->updateStatus("No username specified.", true);
207             $fail = true;
208         }
209
210         if (empty($this->sitename)) {
211             $this->updateStatus("No sitename specified.", true);
212             $fail = true;
213         }
214
215         return !$fail;
216     }
217
218     /**
219      * Basic validation on the administrator user paramters
220      * Side effects: error output if not valid
221      *
222      * @return boolean success
223      */
224     function validateAdmin()
225     {
226         $fail = false;
227
228         if (empty($this->adminNick)) {
229             $this->updateStatus("No initial StatusNet user nickname specified.", true);
230             $fail = true;
231         }
232         if ($this->adminNick && !preg_match('/^[0-9a-z]{1,64}$/', $this->adminNick)) {
233             $this->updateStatus('The user nickname "' . htmlspecialchars($this->adminNick) .
234                          '" is invalid; should be plain letters and numbers no longer than 64 characters.', true);
235             $fail = true;
236         }
237         // @fixme hardcoded list; should use User::allowed_nickname()
238         // if/when it's safe to have loaded the infrastructure here
239         $blacklist = array('main', 'panel', 'twitter', 'settings', 'rsd.xml', 'favorited', 'featured', 'favoritedrss', 'featuredrss', 'rss', 'getfile', 'api', 'groups', 'group', 'peopletag', 'tag', 'user', 'message', 'conversation', 'bookmarklet', 'notice', 'attachment', 'search', 'index.php', 'doc', 'opensearch', 'robots.txt', 'xd_receiver.html', 'facebook');
240         if (in_array($this->adminNick, $blacklist)) {
241             $this->updateStatus('The user nickname "' . htmlspecialchars($this->adminNick) .
242                          '" is reserved.', true);
243             $fail = true;
244         }
245
246         if (empty($this->adminPass)) {
247             $this->updateStatus("No initial StatusNet user password specified.", true);
248             $fail = true;
249         }
250
251         return !$fail;
252     }
253
254     /**
255      * Make sure a site profile was selected
256      *
257      * @return type boolean success
258      */
259     function validateSiteProfile()
260     {
261         $fail = false;
262
263         $sprofile = $this->siteProfile;
264
265         if (empty($sprofile))  {
266             $this->updateStatus("No site profile selected.", true);
267             $fail = true;
268         }
269
270         return !$fail;
271     }
272
273     /**
274      * Set up the database with the appropriate function for the selected type...
275      * Saves database info into $this->db.
276      *
277      * @fixme escape things in the connection string in case we have a funny pass etc
278      * @return mixed array of database connection params on success, false on failure
279      */
280     function setupDatabase()
281     {
282         if ($this->db) {
283             throw new Exception("Bad order of operations: DB already set up.");
284         }
285         $this->updateStatus("Starting installation...");
286
287         if (empty($this->password)) {
288             $auth = '';
289         } else {
290             $auth = ":$this->password";
291         }
292         $scheme = self::$dbModules[$this->dbtype]['scheme'];
293         $dsn = "{$scheme}://{$this->username}{$auth}@{$this->host}/{$this->database}";
294
295         $this->updateStatus("Checking database...");
296         $conn = $this->connectDatabase($dsn);
297
298         // ensure database encoding is UTF8
299         if ($this->dbtype == 'mysql') {
300             // @fixme utf8m4 support for mysql 5.5?
301             // Force the comms charset to utf8 for sanity
302             // This doesn't currently work. :P
303             //$conn->executes('set names utf8');
304         } else if ($this->dbtype == 'pgsql') {
305             $record = $conn->getRow('SHOW server_encoding');
306             if ($record->server_encoding != 'UTF8') {
307                 $this->updateStatus("StatusNet requires UTF8 character encoding. Your database is ". htmlentities($record->server_encoding));
308                 return false;
309             }
310         }
311
312         $res = $this->updateStatus("Creating database tables...");
313         if (!$this->createCoreTables($conn)) {
314             $this->updateStatus("Error creating tables.", true);
315             return false;
316         }
317
318         foreach (array('sms_carrier' => 'SMS carrier',
319                     'notice_source' => 'notice source',
320                     'foreign_services' => 'foreign service')
321               as $scr => $name) {
322             $this->updateStatus(sprintf("Adding %s data to database...", $name));
323             $res = $this->runDbScript($scr.'.sql', $conn);
324             if ($res === false) {
325                 $this->updateStatus(sprintf("Can't run %s script.", $name), true);
326                 return false;
327             }
328         }
329
330         $db = array('type' => $this->dbtype, 'database' => $dsn);
331         return $db;
332     }
333
334     /**
335      * Open a connection to the database.
336      *
337      * @param <type> $dsn
338      * @return <type>
339      */
340     function connectDatabase($dsn)
341     {
342         // @fixme move this someplace more sensible
343         //set_include_path(INSTALLDIR . '/extlib' . PATH_SEPARATOR . get_include_path());
344         require_once 'DB.php';
345         return DB::connect($dsn);
346     }
347
348     /**
349      * Create core tables on the given database connection.
350      *
351      * @param DB_common $conn
352      */
353     function createCoreTables(DB_common $conn)
354     {
355         $schema = Schema::get($conn);
356         $tableDefs = $this->getCoreSchema();
357         foreach ($tableDefs as $name => $def) {
358             if (defined('DEBUG_INSTALLER')) {
359                 echo " $name ";
360             }
361             $schema->ensureTable($name, $def);
362         }
363         return true;
364     }
365
366     /**
367      * Fetch the core table schema definitions.
368      *
369      * @return array of table names => table def arrays
370      */
371     function getCoreSchema()
372     {
373         $schema = array();
374         include INSTALLDIR . '/db/core.php';
375         return $schema;
376     }
377
378     /**
379      * Return a parseable PHP literal for the given value.
380      * This will include quotes for strings, etc.
381      *
382      * @param mixed $val
383      * @return string
384      */
385     function phpVal($val)
386     {
387         return var_export($val, true);
388     }
389
390     /**
391      * Return an array of parseable PHP literal for the given values.
392      * These will include quotes for strings, etc.
393      *
394      * @param mixed $val
395      * @return array
396      */
397     function phpVals($map)
398     {
399         return array_map(array($this, 'phpVal'), $map);
400     }
401
402     /**
403      * Write a stock configuration file.
404      *
405      * @return boolean success
406      *
407      * @fixme escape variables in output in case we have funny chars, apostrophes etc
408      */
409     function writeConf()
410     {
411         $vals = $this->phpVals(array(
412             'sitename' => $this->sitename,
413             'server' => $this->server,
414             'path' => $this->path,
415             'db_database' => $this->db['database'],
416             'db_type' => $this->db['type']
417         ));
418
419         // assemble configuration file in a string
420         $cfg =  "<?php\n".
421                 "if (!defined('STATUSNET') && !defined('LACONICA')) { exit(1); }\n\n".
422
423                 // site name
424                 "\$config['site']['name'] = {$vals['sitename']};\n\n".
425
426                 // site location
427                 "\$config['site']['server'] = {$vals['server']};\n".
428                 "\$config['site']['path'] = {$vals['path']}; \n\n".
429
430                 // checks if fancy URLs are enabled
431                 ($this->fancy ? "\$config['site']['fancy'] = true;\n\n":'').
432
433                 // database
434                 "\$config['db']['database'] = {$vals['db_database']};\n\n".
435                 ($this->db['type'] == 'pgsql' ? "\$config['db']['quote_identifiers'] = true;\n\n":'').
436                 "\$config['db']['type'] = {$vals['db_type']};\n\n";
437
438         // Normalize line endings for Windows servers
439         $cfg = str_replace("\n", PHP_EOL, $cfg);
440
441         // write configuration file out to install directory
442         $res = file_put_contents(INSTALLDIR.'/config.php', $cfg);
443
444         return $res;
445     }
446
447     /**
448      * Write the site profile. We do this after creating the initial user
449      * in case the site profile is set to single user. This gets around the
450      * 'chicken-and-egg' problem of the system requiring a valid user for
451      * single user mode, before the intial user is actually created. Yeah,
452      * we should probably do this in smarter way.
453      *
454      * @return int res number of bytes written
455      */
456     function writeSiteProfile()
457     {
458         $vals = $this->phpVals(array(
459             'site_profile' => $this->siteProfile,
460             'nickname' => $this->adminNick
461         ));
462
463         $cfg =
464         // site profile
465         "\$config['site']['profile'] = {$vals['site_profile']};\n";
466
467         if ($this->siteProfile == "singleuser") {
468             $cfg .= "\$config['singleuser']['nickname'] = {$vals['nickname']};\n\n";
469         } else {
470             $cfg .= "\n";
471         }
472
473         // Normalize line endings for Windows servers
474         $cfg = str_replace("\n", PHP_EOL, $cfg);
475
476         // write configuration file out to install directory
477         $res = file_put_contents(INSTALLDIR.'/config.php', $cfg, FILE_APPEND);
478
479         return $res;
480     }
481
482     /**
483      * Install schema into the database
484      *
485      * @param string    $filename location of database schema file
486      * @param DB_common $conn     connection to database
487      *
488      * @return boolean - indicating success or failure
489      */
490     function runDbScript($filename, DB_common $conn)
491     {
492         $sql = trim(file_get_contents(INSTALLDIR . '/db/' . $filename));
493         $stmts = explode(';', $sql);
494         foreach ($stmts as $stmt) {
495             $stmt = trim($stmt);
496             if (!mb_strlen($stmt)) {
497                 continue;
498             }
499             try {
500                 $res = $conn->simpleQuery($stmt);
501             } catch (Exception $e) {
502                 $error = $e->getMessage();
503                 $this->updateStatus("ERROR ($error) for SQL '$stmt'");
504                 return false;
505             }
506         }
507         return true;
508     }
509
510     /**
511      * Create the initial admin user account.
512      * Side effect: may load portions of StatusNet framework.
513      * Side effect: outputs program info
514      */
515     function registerInitialUser()
516     {
517         require_once INSTALLDIR . '/lib/common.php';
518
519         $data = array('nickname' => $this->adminNick,
520                       'password' => $this->adminPass,
521                       'fullname' => $this->adminNick);
522         if ($this->adminEmail) {
523             $data['email'] = $this->adminEmail;
524         }
525         $user = User::register($data);
526
527         if (empty($user)) {
528             return false;
529         }
530
531         // give initial user carte blanche
532
533         $user->grantRole('owner');
534         $user->grantRole('moderator');
535         $user->grantRole('administrator');
536
537         // Attempt to do a remote subscribe to update@status.net
538         // Will fail if instance is on a private network.
539
540         if ($this->adminUpdates && class_exists('Ostatus_profile')) {
541             try {
542                 $oprofile = Ostatus_profile::ensureProfileURL('http://update.status.net/');
543                 Subscription::start($user->getProfile(), $oprofile->localProfile());
544                 $this->updateStatus("Set up subscription to <a href='http://update.status.net/'>update@status.net</a>.");
545             } catch (Exception $e) {
546                 $this->updateStatus("Could not set up subscription to <a href='http://update.status.net/'>update@status.net</a>.", true);
547             }
548         }
549
550         return true;
551     }
552
553     /**
554      * The beef of the installer!
555      * Create database, config file, and admin user.
556      *
557      * Prerequisites: validation of input data.
558      *
559      * @return boolean success
560      */
561     function doInstall()
562     {
563         $this->updateStatus("Initializing...");
564         ini_set('display_errors', 1);
565         error_reporting(E_ALL);
566         if (!defined('STATUSNET')) {
567             define('STATUSNET', 1);
568         }
569         require_once INSTALLDIR . '/lib/framework.php';
570         StatusNet::initDefaults($this->server, $this->path);
571
572         try {
573             $this->db = $this->setupDatabase();
574             if (!$this->db) {
575                 // database connection failed, do not move on to create config file.
576                 return false;
577             }
578         } catch (Exception $e) {
579             // Lower-level DB error!
580             $this->updateStatus("Database error: " . $e->getMessage(), true);
581             return false;
582         }
583
584         // Make sure we can write to the file twice
585         $oldUmask = umask(000); 
586
587         if (!$this->skipConfig) {
588             $this->updateStatus("Writing config file...");
589             $res = $this->writeConf();
590
591             if (!$res) {
592                 $this->updateStatus("Can't write config file.", true);
593                 return false;
594             }
595         }
596
597         if (!empty($this->adminNick)) {
598             // Okay, cross fingers and try to register an initial user
599             if ($this->registerInitialUser()) {
600                 $this->updateStatus(
601                     "An initial user with the administrator role has been created."
602                 );
603             } else {
604                 $this->updateStatus(
605                     "Could not create initial StatusNet user (administrator).",
606                     true
607                 );
608                 return false;
609             }
610         }
611
612         if (!$this->skipConfig) {
613             $this->updateStatus("Setting site profile...");
614             $res = $this->writeSiteProfile();
615
616             if (!$res) {
617                 $this->updateStatus("Can't write to config file.", true);
618                 return false;
619             }
620         }
621
622         // Restore original umask
623         umask($oldUmask);
624         // Set permissions back to something decent
625         chmod(INSTALLDIR.'/config.php', 0644);
626         
627         /*
628             TODO https needs to be considered
629         */
630         $link = "http://".$this->server.'/'.$this->path;
631
632         $this->updateStatus("StatusNet has been installed at $link");
633         $this->updateStatus(
634             "<strong>DONE!</strong> You can visit your <a href='$link'>new StatusNet site</a> (login as '$this->adminNick'). If this is your first StatusNet install, you may want to poke around our <a href='http://status.net/wiki/Getting_started'>Getting Started guide</a>."
635         );
636
637         return true;
638     }
639
640     /**
641      * Output a pre-install-time warning message
642      * @param string $message HTML ok, but should be plaintext-able
643      * @param string $submessage HTML ok, but should be plaintext-able
644      */
645     abstract function warning($message, $submessage='');
646
647     /**
648      * Output an install-time progress message
649      * @param string $message HTML ok, but should be plaintext-able
650      * @param boolean $error true if this should be marked as an error condition
651      */
652     abstract function updateStatus($status, $error=false);
653
654 }