Implement installation routines modularly
This allows us to "merge" module installation routines with our main installer routines. refs #7163
This commit is contained in:
parent
2fd22261eb
commit
216c072024
|
@ -1,12 +1,17 @@
|
|||
<div id="installer">
|
||||
<div class="report">
|
||||
<?php $firstLine = true; ?>
|
||||
<?php foreach ($report as $entry): ?>
|
||||
<p<?= false === $entry->state ? ' class="error"' : ''; ?>>
|
||||
<?= $entry->message; ?>
|
||||
</p>
|
||||
<?php if ($entry !== ''): ?>
|
||||
<?php if (false === $firstLine): ?>
|
||||
<div class="line-separator"></div>
|
||||
<?php endif ?>
|
||||
<?= $entry; ?>
|
||||
<?php $firstLine = false; ?>
|
||||
<?php endif ?>
|
||||
<?php endforeach ?>
|
||||
<?php if ($success): ?>
|
||||
<p class="success"><?= t('Congratulations! The installation of Icinga Web 2 was successfully completed.'); ?></p>
|
||||
<p class="success"><?= t('Congratulations! The installation of Icinga Web 2 has been successfully completed.'); ?></p>
|
||||
<?php else: ?>
|
||||
<p class="failure"><?= t('Sorry! The installation of Icinga Web 2 has failed.'); ?></p>
|
||||
<?php endif ?>
|
||||
|
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
// {{{ICINGA_LICENSE_HEADER}}}
|
||||
// {{{ICINGA_LICENSE_HEADER}}}
|
||||
|
||||
namespace Icinga\Application\Installation;
|
||||
|
||||
use Exception;
|
||||
use Zend_Config;
|
||||
use Icinga\Web\Setup\Step;
|
||||
use Icinga\Application\Config;
|
||||
use Icinga\Data\ResourceFactory;
|
||||
use Icinga\Config\PreservingIniWriter;
|
||||
use Icinga\Authentication\Backend\DbUserBackend;
|
||||
|
||||
class AuthenticationStep extends Step
|
||||
{
|
||||
protected $data;
|
||||
|
||||
protected $dbError;
|
||||
|
||||
protected $authIniError;
|
||||
|
||||
protected $permIniError;
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function apply()
|
||||
{
|
||||
$success = $this->createAuthenticationIni();
|
||||
if (isset($this->data['adminAccountData']['resourceConfig'])) {
|
||||
$success &= $this->createAccount();
|
||||
}
|
||||
|
||||
$success &= $this->defineInitialAdmin();
|
||||
return $success;
|
||||
}
|
||||
|
||||
protected function createAuthenticationIni()
|
||||
{
|
||||
$config = array();
|
||||
$backendConfig = $this->data['backendConfig'];
|
||||
$backendName = $backendConfig['name'];
|
||||
unset($backendConfig['name']);
|
||||
$config[$backendName] = $backendConfig;
|
||||
if (isset($this->data['resourceName'])) {
|
||||
$config[$backendName]['resource'] = $this->data['resourceName'];
|
||||
}
|
||||
|
||||
try {
|
||||
$writer = new PreservingIniWriter(array(
|
||||
'config' => new Zend_Config($config),
|
||||
'filename' => Config::resolvePath('authentication.ini'),
|
||||
'filemode' => octdec($this->data['fileMode'])
|
||||
));
|
||||
$writer->write();
|
||||
} catch (Exception $e) {
|
||||
$this->authIniError = $e;
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->authIniError = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function defineInitialAdmin()
|
||||
{
|
||||
$this->permIniError = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function createAccount()
|
||||
{
|
||||
try {
|
||||
$backend = new DbUserBackend(
|
||||
ResourceFactory::createResource(new Zend_Config($this->data['adminAccountData']['resourceConfig']))
|
||||
);
|
||||
|
||||
if (array_search($this->data['adminAccountData']['username'], $backend->listUsers()) === false) {
|
||||
$backend->addUser(
|
||||
$this->data['adminAccountData']['username'],
|
||||
$this->data['adminAccountData']['password']
|
||||
);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->dbError = $e;
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->dbError = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getSummary()
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getReport()
|
||||
{
|
||||
$report = '';
|
||||
if ($this->authIniError === false) {
|
||||
$message = t('Authentication configuration has been successfully written to: %s');
|
||||
$report .= '<p>' . sprintf($message, Config::resolvePath('authentication.ini')) . '</p>';
|
||||
} elseif ($this->authIniError !== null) {
|
||||
$message = t('Authentication configuration could not be written to: %s; An error occured:');
|
||||
$report .= '<p class="error">' . sprintf($message, Config::resolvePath('authentication.ini')) . '</p>'
|
||||
. '<p>' . $this->authIniError->getMessage() . '</p>';
|
||||
}
|
||||
|
||||
if ($this->dbError === false) {
|
||||
$message = t('Account "%s" has been successfully created.');
|
||||
$report .= '<p>' . sprintf($message, $this->data['adminAccountData']['username']) . '</p>';
|
||||
} elseif ($this->dbError !== null) {
|
||||
$message = t('Unable to create account "%s". An error occured:');
|
||||
$report .= '<p class="error">' . sprintf($message, $this->data['adminAccountData']['username']) . '</p>'
|
||||
. '<p>' . $this->dbError->getMessage() . '</p>';
|
||||
}
|
||||
|
||||
if ($this->permIniError === false) {
|
||||
$message = t('Account "%s" has been successfully defined as initial administrator.');
|
||||
$report .= '<p>' . sprintf($message, $this->data['adminAccountData']['username']) . '</p>';
|
||||
} elseif ($this->permIniError !== null) {
|
||||
$message = t('Unable to define account "%s" as initial administrator. An error occured:');
|
||||
$report .= '<p class="error">' . sprintf($message, $this->data['adminAccountData']['username']) . '</p>'
|
||||
. '<p>' . $this->permIniError->getMessage() . '</p>';
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
// {{{ICINGA_LICENSE_HEADER}}}
|
||||
// {{{ICINGA_LICENSE_HEADER}}}
|
||||
|
||||
namespace Icinga\Application\Installation;
|
||||
|
||||
use Exception;
|
||||
use PDOException;
|
||||
use Icinga\Web\Setup\Step;
|
||||
use Icinga\Web\Setup\DbTool;
|
||||
use Icinga\Application\Icinga;
|
||||
use Icinga\Application\Platform;
|
||||
use Icinga\Exception\InstallException;
|
||||
|
||||
class DatabaseStep extends Step
|
||||
{
|
||||
protected $data;
|
||||
|
||||
protected $error;
|
||||
|
||||
protected $messages;
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
$this->messages = array();
|
||||
}
|
||||
|
||||
public function apply()
|
||||
{
|
||||
$resourceConfig = $this->data['resourceConfig'];
|
||||
if (isset($this->data['adminName'])) {
|
||||
$resourceConfig['username'] = $this->data['adminName'];
|
||||
if (isset($this->data['adminPassword'])) {
|
||||
$resourceConfig['password'] = $this->data['adminPassword'];
|
||||
}
|
||||
}
|
||||
|
||||
$db = new DbTool($resourceConfig);
|
||||
|
||||
try {
|
||||
if ($resourceConfig['db'] === 'mysql') {
|
||||
$this->setupMysqlDatabase($db);
|
||||
} elseif ($resourceConfig['db'] === 'pgsql') {
|
||||
$this->setupPgsqlDatabase($db);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->error = $e;
|
||||
throw new InstallException();
|
||||
}
|
||||
|
||||
$this->error = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function setupMysqlDatabase(DbTool $db)
|
||||
{
|
||||
try {
|
||||
$db->connectToDb();
|
||||
$this->log(
|
||||
t('Successfully connected to existing database "%s"...'),
|
||||
$this->data['resourceConfig']['dbname']
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
$db->connectToHost();
|
||||
$this->log(t('Creating new database "%s"...'), $this->data['resourceConfig']['dbname']);
|
||||
$db->exec('CREATE DATABASE ' . $db->quoteIdentifier($this->data['resourceConfig']['dbname']));
|
||||
$db->reconnect($this->data['resourceConfig']['dbname']);
|
||||
}
|
||||
|
||||
if ($db->hasLogin($this->data['resourceConfig']['username'])) {
|
||||
$this->log(t('Login "%s" already exists...'), $this->data['resourceConfig']['username']);
|
||||
} else {
|
||||
$this->log(t('Creating login "%s"...'), $this->data['resourceConfig']['username']);
|
||||
$db->addLogin($this->data['resourceConfig']['username'], $this->data['resourceConfig']['password']);
|
||||
}
|
||||
|
||||
if (array_search('account', $db->listTables()) !== false) {
|
||||
$this->log(t('Database schema already exists...'));
|
||||
} else {
|
||||
$this->log(t('Creating database schema...'));
|
||||
$db->import(Icinga::app()->getApplicationDir() . '/../etc/schema/mysql.sql');
|
||||
}
|
||||
|
||||
$privileges = array('SELECT', 'INSERT', 'UPDATE', 'DELETE', 'EXECUTE', 'CREATE TEMPORARY TABLES');
|
||||
if ($db->checkPrivileges(array_merge($privileges, array('GRANT OPTION')))) {
|
||||
$this->log(t('Granting required privileges to login "%s"...'), $this->data['resourceConfig']['username']);
|
||||
$db->exec(sprintf(
|
||||
"GRANT %s ON %s.* TO %s@%s",
|
||||
join(',', $privileges),
|
||||
$db->quoteIdentifier($this->data['resourceConfig']['dbname']),
|
||||
$db->quoteIdentifier($this->data['resourceConfig']['username']),
|
||||
$db->quoteIdentifier(Platform::getFqdn())
|
||||
));
|
||||
} else {
|
||||
$this->log(
|
||||
t('Required privileges were already granted to login "%s".'),
|
||||
$this->data['resourceConfig']['username']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function setupPgsqlDatabase(DbTool $db)
|
||||
{
|
||||
try {
|
||||
$db->connectToDb();
|
||||
$this->log(
|
||||
t('Successfully connected to existing database "%s"...'),
|
||||
$this->data['resourceConfig']['dbname']
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
$db->connectToHost();
|
||||
$this->log(t('Creating new database "%s"...'), $this->data['resourceConfig']['dbname']);
|
||||
$db->exec('CREATE DATABASE ' . $db->quoteIdentifier($this->data['resourceConfig']['dbname']));
|
||||
$db->reconnect($this->data['resourceConfig']['dbname']);
|
||||
}
|
||||
|
||||
if ($db->hasLogin($this->data['resourceConfig']['username'])) {
|
||||
$this->log(t('Login "%s" already exists...'), $this->data['resourceConfig']['username']);
|
||||
} else {
|
||||
$this->log(t('Creating login "%s"...'), $this->data['resourceConfig']['username']);
|
||||
$db->addLogin($this->data['resourceConfig']['username'], $this->data['resourceConfig']['password']);
|
||||
}
|
||||
|
||||
if (array_search('account', $db->listTables()) !== false) {
|
||||
$this->log(t('Database schema already exists...'));
|
||||
} else {
|
||||
$this->log(t('Creating database schema...'));
|
||||
$db->import(Icinga::app()->getApplicationDir() . '/../etc/schema/pgsql.sql');
|
||||
}
|
||||
|
||||
$privileges = array('SELECT', 'INSERT', 'UPDATE', 'DELETE');
|
||||
if ($db->checkPrivileges(array_merge($privileges, array('GRANT OPTION')))) {
|
||||
$this->log(t('Granting required privileges to login "%s"...'), $this->data['resourceConfig']['username']);
|
||||
$db->exec(sprintf(
|
||||
"GRANT %s ON TABLE account TO %s",
|
||||
join(',', $privileges),
|
||||
$db->quoteIdentifier($this->data['resourceConfig']['username'])
|
||||
));
|
||||
$db->exec(sprintf(
|
||||
"GRANT %s ON TABLE preference TO %s",
|
||||
join(',', $privileges),
|
||||
$db->quoteIdentifier($this->data['resourceConfig']['username'])
|
||||
));
|
||||
} else {
|
||||
$this->log(
|
||||
t('Required privileges were already granted to login "%s".'),
|
||||
$this->data['resourceConfig']['username']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function getSummary()
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getReport()
|
||||
{
|
||||
if ($this->error === false) {
|
||||
return '<p>' . join('</p><p>', $this->messages) . '</p>'
|
||||
. '<p>' . t('The database has been fully set up!') . '</p>';
|
||||
} elseif ($this->error !== null) {
|
||||
$message = t('Failed to fully setup the database. An error occured:');
|
||||
return '<p>' . join('</p><p>', $this->messages) . '</p>'
|
||||
. '<p class="error">' . $message . '</p><p>' . $this->error->getMessage() . '</p>';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
protected function log()
|
||||
{
|
||||
$this->messages[] = call_user_func_array('sprintf', func_get_args());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
// {{{ICINGA_LICENSE_HEADER}}}
|
||||
// {{{ICINGA_LICENSE_HEADER}}}
|
||||
|
||||
namespace Icinga\Application\Installation;
|
||||
|
||||
use Exception;
|
||||
use Zend_Config;
|
||||
use Icinga\Web\Setup\Step;
|
||||
use Icinga\Application\Config;
|
||||
use Icinga\Config\PreservingIniWriter;
|
||||
|
||||
class GeneralConfigStep extends Step
|
||||
{
|
||||
protected $data;
|
||||
|
||||
protected $error;
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function apply()
|
||||
{
|
||||
$config = array();
|
||||
foreach ($this->data['generalConfig'] as $sectionAndPropertyName => $value) {
|
||||
list($section, $property) = explode('_', $sectionAndPropertyName);
|
||||
$config[$section][$property] = $value;
|
||||
}
|
||||
|
||||
$config['preferences']['type'] = $this->data['preferencesType'];
|
||||
if (isset($this->data['preferencesResource'])) {
|
||||
$config['preferences']['resource'] = $this->data['preferencesResource'];
|
||||
}
|
||||
|
||||
try {
|
||||
$writer = new PreservingIniWriter(array(
|
||||
'config' => new Zend_Config($config),
|
||||
'filename' => Config::resolvePath('config.ini'),
|
||||
'filemode' => octdec($this->data['fileMode'])
|
||||
));
|
||||
$writer->write();
|
||||
} catch (Exception $e) {
|
||||
$this->error = $e;
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->error = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getSummary()
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getReport()
|
||||
{
|
||||
if ($this->error === false) {
|
||||
$message = t('General configuration has been successfully written to: %s');
|
||||
return '<p>' . sprintf($message, Config::resolvePath('config.ini')) . '</p>';
|
||||
} elseif ($this->error !== null) {
|
||||
$message = t('General configuration could not be written to: %s; An error occured:');
|
||||
return '<p class="error">' . sprintf($message, Config::resolvePath('config.ini')) . '</p>'
|
||||
. '<p>' . $this->error->getMessage() . '</p>';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
// {{{ICINGA_LICENSE_HEADER}}}
|
||||
// {{{ICINGA_LICENSE_HEADER}}}
|
||||
|
||||
namespace Icinga\Application\Installation;
|
||||
|
||||
use Exception;
|
||||
use Zend_Config;
|
||||
use Icinga\Web\Setup\Step;
|
||||
use Icinga\Application\Config;
|
||||
use Icinga\Config\PreservingIniWriter;
|
||||
|
||||
class ResourceStep extends Step
|
||||
{
|
||||
protected $data;
|
||||
|
||||
protected $error;
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function apply()
|
||||
{
|
||||
$resourceConfig = array();
|
||||
if (isset($this->data['dbResourceConfig'])) {
|
||||
$dbConfig = $this->data['dbResourceConfig'];
|
||||
$resourceName = $dbConfig['name'];
|
||||
unset($dbConfig['name']);
|
||||
$resourceConfig[$resourceName] = $dbConfig;
|
||||
}
|
||||
|
||||
if (isset($this->data['ldapResourceConfig'])) {
|
||||
$ldapConfig = $this->data['ldapResourceConfig'];
|
||||
$resourceName = $ldapConfig['name'];
|
||||
unset($ldapConfig['name']);
|
||||
$resourceConfig[$resourceName] = $ldapConfig;
|
||||
}
|
||||
|
||||
try {
|
||||
$writer = new PreservingIniWriter(array(
|
||||
'config' => new Zend_Config($resourceConfig),
|
||||
'filename' => Config::resolvePath('resources.ini'),
|
||||
'filemode' => octdec($this->data['fileMode'])
|
||||
));
|
||||
$writer->write();
|
||||
} catch (Exception $e) {
|
||||
$this->error = $e;
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->error = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getSummary()
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getReport()
|
||||
{
|
||||
if ($this->error === false) {
|
||||
$message = t('Resource configuration has been successfully written to: %s');
|
||||
return '<p>' . sprintf($message, Config::resolvePath('resources.ini')) . '</p>';
|
||||
} elseif ($this->error !== null) {
|
||||
$message = t('Resource configuration could not be written to: %s; An error occured:');
|
||||
return '<p class="error">' . sprintf($message, Config::resolvePath('resources.ini')) . '</p>'
|
||||
. '<p>' . $this->error->getMessage() . '</p>';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
|
@ -1,487 +0,0 @@
|
|||
<?php
|
||||
// {{{ICINGA_LICENSE_HEADER}}}
|
||||
// {{{ICINGA_LICENSE_HEADER}}}
|
||||
|
||||
namespace Icinga\Application;
|
||||
|
||||
use Exception;
|
||||
use Zend_Config;
|
||||
use PDOException;
|
||||
use Icinga\Web\Setup\DbTool;
|
||||
use Icinga\Web\Setup\Installer;
|
||||
use Icinga\Data\ResourceFactory;
|
||||
use Icinga\Config\PreservingIniWriter;
|
||||
use Icinga\Authentication\Backend\DbUserBackend;
|
||||
|
||||
/**
|
||||
* Icinga Web 2 Installer
|
||||
*/
|
||||
class WebInstaller implements Installer
|
||||
{
|
||||
/**
|
||||
* The setup wizard's page data
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $pageData;
|
||||
|
||||
/**
|
||||
* The report entries
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $report;
|
||||
|
||||
/**
|
||||
* Create a new web installer
|
||||
*
|
||||
* @param array $pageData The setup wizard's page data
|
||||
*/
|
||||
public function __construct(array $pageData)
|
||||
{
|
||||
$this->pageData = $pageData;
|
||||
$this->report = array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @see Installer::run()
|
||||
*/
|
||||
public function run()
|
||||
{
|
||||
$success = true;
|
||||
|
||||
if (isset($this->pageData['setup_db_resource'])
|
||||
&& ! $this->pageData['setup_db_resource']['skip_validation']
|
||||
&& (false === isset($this->pageData['setup_database_creation'])
|
||||
|| ! $this->pageData['setup_database_creation']['skip_validation']
|
||||
)
|
||||
) {
|
||||
try {
|
||||
$this->setupDatabase();
|
||||
} catch (Exception $e) {
|
||||
$this->log(sprintf(t('Failed to set up the database: %s'), $e->getMessage()), false);
|
||||
return false; // Bail out as early as possible as not being able to setup the database is fatal
|
||||
}
|
||||
|
||||
$this->log(t('The database has been successfully set up!'));
|
||||
}
|
||||
|
||||
$configIniPath = Config::resolvePath('config.ini');
|
||||
try {
|
||||
$this->writeConfigIni($configIniPath);
|
||||
$this->log(sprintf(t('Successfully created: %s'), $configIniPath));
|
||||
} catch (Exception $e) {
|
||||
$success = false;
|
||||
$this->log(sprintf(t('Unable to create: %s (%s)'), $configIniPath, $e->getMessage()), false);
|
||||
}
|
||||
|
||||
$resourcesIniPath = Config::resolvePath('resources.ini');
|
||||
try {
|
||||
$this->writeResourcesIni($resourcesIniPath);
|
||||
$this->log(sprintf(t('Successfully created: %s'), $resourcesIniPath));
|
||||
} catch (Exception $e) {
|
||||
$success = false;
|
||||
$this->log(sprintf(t('Unable to create: %s (%s)'), $resourcesIniPath, $e->getMessage()), false);
|
||||
}
|
||||
|
||||
$authenticationIniPath = Config::resolvePath('authentication.ini');
|
||||
try {
|
||||
$this->writeAuthenticationIni($authenticationIniPath);
|
||||
$this->log(sprintf(t('Successfully created: %s'), $authenticationIniPath));
|
||||
} catch (Exception $e) {
|
||||
$success = false;
|
||||
$this->log(sprintf(t('Unable to create: %s (%s)'), $authenticationIniPath, $e->getMessage()), false);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->setupAdminAccount();
|
||||
$this->log(t('Successfully defined initial administrative account.'));
|
||||
} catch (Exception $e) {
|
||||
$success = false;
|
||||
$this->log(sprintf(t('Failed to define initial administrative account: %s'), $e->getMessage()), false);
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write application configuration to the given filepath
|
||||
*
|
||||
* @param string $configPath
|
||||
*/
|
||||
protected function writeConfigIni($configPath)
|
||||
{
|
||||
$preferencesConfig = array();
|
||||
$preferencesConfig['type'] = $this->pageData['setup_preferences_type']['type'];
|
||||
if ($this->pageData['setup_preferences_type']['type'] === 'db') {
|
||||
$preferencesConfig['resource'] = $this->pageData['setup_db_resource']['name'];
|
||||
}
|
||||
|
||||
$loggingConfig = array();
|
||||
$loggingConfig['log'] = $this->pageData['setup_general_config']['logging_log'];
|
||||
if ($this->pageData['setup_general_config']['logging_log'] !== 'none') {
|
||||
$loggingConfig['level'] = $this->pageData['setup_general_config']['logging_level'];
|
||||
if ($this->pageData['setup_general_config']['logging_log'] === 'syslog') {
|
||||
$loggingConfig['application'] = $this->pageData['setup_general_config']['logging_application'];
|
||||
//$loggingConfig['facility'] = $this->pageData['setup_general_config']['logging_facility'];
|
||||
} else { // $this->pageData['setup_general_config']['logging_log'] === 'file'
|
||||
$loggingConfig['file'] = $this->pageData['setup_general_config']['logging_file'];
|
||||
}
|
||||
}
|
||||
|
||||
$config = array(
|
||||
'global' => array(
|
||||
'modulepath' => $this->pageData['setup_general_config']['global_modulePath'],
|
||||
'filemode' => $this->pageData['setup_general_config']['global_filemode']
|
||||
),
|
||||
'preferences' => $preferencesConfig,
|
||||
'logging' => $loggingConfig
|
||||
);
|
||||
|
||||
$writer = new PreservingIniWriter(array(
|
||||
'config' => new Zend_Config($config),
|
||||
'filename' => $configPath,
|
||||
'filemode' => octdec($this->pageData['setup_general_config']['global_filemode'])
|
||||
));
|
||||
$writer->write();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write resource configuration to the given filepath
|
||||
*
|
||||
* @param string $configPath
|
||||
*/
|
||||
protected function writeResourcesIni($configPath)
|
||||
{
|
||||
$resourceConfig = array();
|
||||
if (isset($this->pageData['setup_db_resource'])) {
|
||||
$resourceConfig[$this->pageData['setup_db_resource']['name']] = array(
|
||||
'type' => $this->pageData['setup_db_resource']['type'],
|
||||
'db' => $this->pageData['setup_db_resource']['db'],
|
||||
'host' => $this->pageData['setup_db_resource']['host'],
|
||||
'port' => $this->pageData['setup_db_resource']['port'],
|
||||
'dbname' => $this->pageData['setup_db_resource']['dbname'],
|
||||
'username' => $this->pageData['setup_db_resource']['username'],
|
||||
'password' => $this->pageData['setup_db_resource']['password']
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($this->pageData['setup_ldap_resource'])) {
|
||||
$resourceConfig[$this->pageData['setup_ldap_resource']['name']] = array(
|
||||
'type' => $this->pageData['setup_ldap_resource']['type'],
|
||||
'hostname' => $this->pageData['setup_ldap_resource']['hostname'],
|
||||
'port' => $this->pageData['setup_ldap_resource']['port'],
|
||||
'root_dn' => $this->pageData['setup_ldap_resource']['root_dn'],
|
||||
'bind_dn' => $this->pageData['setup_ldap_resource']['bind_dn'],
|
||||
'bind_pw' => $this->pageData['setup_ldap_resource']['bind_pw']
|
||||
);
|
||||
}
|
||||
|
||||
if (empty($resourceConfig)) {
|
||||
return; // No need to write nothing :)
|
||||
}
|
||||
|
||||
$writer = new PreservingIniWriter(array(
|
||||
'config' => new Zend_Config($resourceConfig),
|
||||
'filename' => $configPath,
|
||||
'filemode' => octdec($this->pageData['setup_general_config']['global_filemode'])
|
||||
));
|
||||
$writer->write();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write authentication backend configuration to the given filepath
|
||||
*
|
||||
* @param string $configPath
|
||||
*/
|
||||
protected function writeAuthenticationIni($configPath)
|
||||
{
|
||||
$backendConfig = array();
|
||||
if ($this->pageData['setup_authentication_type']['type'] === 'db') {
|
||||
$backendConfig[$this->pageData['setup_authentication_backend']['name']] = array(
|
||||
'backend' => $this->pageData['setup_authentication_backend']['backend'],
|
||||
'resource' => $this->pageData['setup_db_resource']['name']
|
||||
);
|
||||
} elseif ($this->pageData['setup_authentication_type']['type'] === 'ldap') {
|
||||
$backendConfig[$this->pageData['setup_authentication_backend']['backend']] = array(
|
||||
'backend' => $this->pageData['setup_authentication_backend']['backend'],
|
||||
'resource' => $this->pageData['setup_ldap_resource']['name'],
|
||||
'base_dn' => $this->pageData['setup_authentication_backend']['base_dn'],
|
||||
'user_class' => $this->pageData['setup_authentication_backend']['user_class'],
|
||||
'user_name_attribute' => $this->pageData['setup_authentication_backend']['user_name_attribute']
|
||||
);
|
||||
} else { // $this->pageData['setup_authentication_type']['type'] === 'autologin'
|
||||
$backendConfig[$this->pageData['setup_authentication_backend']['name']] = array(
|
||||
'backend' => $this->pageData['setup_authentication_backend']['backend'],
|
||||
'strip_username_regexp' => $this->pageData['setup_authentication_backend']['strip_username_regexp']
|
||||
);
|
||||
}
|
||||
|
||||
$writer = new PreservingIniWriter(array(
|
||||
'config' => new Zend_Config($backendConfig),
|
||||
'filename' => $configPath,
|
||||
'filemode' => octdec($this->pageData['setup_general_config']['global_filemode'])
|
||||
));
|
||||
$writer->write();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the database
|
||||
*/
|
||||
protected function setupDatabase()
|
||||
{
|
||||
$resourceConfig = $this->pageData['setup_db_resource'];
|
||||
if (isset($this->pageData['setup_database_creation'])) {
|
||||
$resourceConfig['username'] = $this->pageData['setup_database_creation']['username'];
|
||||
$resourceConfig['password'] = $this->pageData['setup_database_creation']['password'];
|
||||
}
|
||||
|
||||
$db = new DbTool($resourceConfig);
|
||||
if ($resourceConfig['db'] === 'mysql') {
|
||||
$this->setupMysqlDatabase($db);
|
||||
} elseif ($resourceConfig['db'] === 'pgsql') {
|
||||
$this->setupPgsqlDatabase($db);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup a MySQL database
|
||||
*
|
||||
* @param DbTool $db The database connection wrapper to use
|
||||
*/
|
||||
private function setupMysqlDatabase(DbTool $db)
|
||||
{
|
||||
try {
|
||||
$db->connectToDb();
|
||||
$this->log(sprintf(
|
||||
t('Successfully connected to existing database "%s"...'),
|
||||
$this->pageData['setup_db_resource']['dbname']
|
||||
));
|
||||
} catch (PDOException $e) {
|
||||
$db->connectToHost();
|
||||
$this->log(sprintf(
|
||||
t('Creating new database "%s"...'),
|
||||
$this->pageData['setup_db_resource']['dbname']
|
||||
));
|
||||
$db->exec('CREATE DATABASE ' . $db->quoteIdentifier($this->pageData['setup_db_resource']['dbname']));
|
||||
$db->reconnect($this->pageData['setup_db_resource']['dbname']);
|
||||
}
|
||||
|
||||
if ($db->hasLogin($this->pageData['setup_db_resource']['username'])) {
|
||||
$this->log(sprintf(
|
||||
t('Login "%s" already exists...'),
|
||||
$this->pageData['setup_db_resource']['username']
|
||||
));
|
||||
} else {
|
||||
$this->log(sprintf(
|
||||
t('Creating login "%s"...'),
|
||||
$this->pageData['setup_db_resource']['username']
|
||||
));
|
||||
$db->addLogin(
|
||||
$this->pageData['setup_db_resource']['username'],
|
||||
$this->pageData['setup_db_resource']['password']
|
||||
);
|
||||
}
|
||||
|
||||
if (array_search('account', $db->listTables()) !== false) {
|
||||
$this->log(t('Database schema already exists...'));
|
||||
} else {
|
||||
$this->log(t('Creating database schema...'));
|
||||
$db->import(Icinga::app()->getApplicationDir() . '/../etc/schema/mysql.sql');
|
||||
}
|
||||
|
||||
$privileges = array('SELECT', 'INSERT', 'UPDATE', 'DELETE', 'EXECUTE', 'CREATE TEMPORARY TABLES');
|
||||
if ($db->checkPrivileges(array_merge($privileges, array('GRANT OPTION')))) {
|
||||
$this->log(sprintf(
|
||||
t('Granting required privileges to login "%s"...'),
|
||||
$this->pageData['setup_db_resource']['username']
|
||||
));
|
||||
$db->exec(sprintf(
|
||||
"GRANT %s ON %s.* TO %s@%s",
|
||||
join(',', $privileges),
|
||||
$db->quoteIdentifier($this->pageData['setup_db_resource']['dbname']),
|
||||
$db->quoteIdentifier($this->pageData['setup_db_resource']['username']),
|
||||
$db->quoteIdentifier(Platform::getFqdn())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup a PostgreSQL database
|
||||
*
|
||||
* @param DbTool $db The database connection wrapper to use
|
||||
*/
|
||||
private function setupPgsqlDatabase(DbTool $db)
|
||||
{
|
||||
try {
|
||||
$db->connectToDb();
|
||||
$this->log(sprintf(
|
||||
t('Successfully connected to existing database "%s"...'),
|
||||
$this->pageData['setup_db_resource']['dbname']
|
||||
));
|
||||
} catch (PDOException $e) {
|
||||
$db->connectToHost();
|
||||
$this->log(sprintf(
|
||||
t('Creating new database "%s"...'),
|
||||
$this->pageData['setup_db_resource']['dbname']
|
||||
));
|
||||
$db->exec('CREATE DATABASE ' . $db->quoteIdentifier($this->pageData['setup_db_resource']['dbname']));
|
||||
$db->reconnect($this->pageData['setup_db_resource']['dbname']);
|
||||
}
|
||||
|
||||
if ($db->hasLogin($this->pageData['setup_db_resource']['username'])) {
|
||||
$this->log(sprintf(
|
||||
t('Login "%s" already exists...'),
|
||||
$this->pageData['setup_db_resource']['username']
|
||||
));
|
||||
} else {
|
||||
$this->log(sprintf(
|
||||
t('Creating login "%s"...'),
|
||||
$this->pageData['setup_db_resource']['username']
|
||||
));
|
||||
$db->addLogin(
|
||||
$this->pageData['setup_db_resource']['username'],
|
||||
$this->pageData['setup_db_resource']['password']
|
||||
);
|
||||
}
|
||||
|
||||
if (array_search('account', $db->listTables()) !== false) {
|
||||
$this->log(t('Database schema already exists...'));
|
||||
} else {
|
||||
$this->log(t('Creating database schema...'));
|
||||
$db->import(Icinga::app()->getApplicationDir() . '/../etc/schema/pgsql.sql');
|
||||
}
|
||||
|
||||
$privileges = array('SELECT', 'INSERT', 'UPDATE', 'DELETE');
|
||||
if ($db->checkPrivileges(array_merge($privileges, array('GRANT OPTION')))) {
|
||||
$this->log(sprintf(
|
||||
t('Granting required privileges to login "%s"...'),
|
||||
$this->pageData['setup_db_resource']['username']
|
||||
));
|
||||
$db->exec(sprintf(
|
||||
"GRANT %s ON TABLE account TO %s",
|
||||
join(',', $privileges),
|
||||
$db->quoteIdentifier($this->pageData['setup_db_resource']['username'])
|
||||
));
|
||||
$db->exec(sprintf(
|
||||
"GRANT %s ON TABLE preference TO %s",
|
||||
join(',', $privileges),
|
||||
$db->quoteIdentifier($this->pageData['setup_db_resource']['username'])
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the initial administrative account
|
||||
*/
|
||||
protected function setupAdminAccount()
|
||||
{
|
||||
if ($this->pageData['setup_admin_account']['user_type'] === 'new_user'
|
||||
&& ! $this->pageData['setup_db_resource']['skip_validation']
|
||||
&& (false === isset($this->pageData['setup_database_creation'])
|
||||
|| ! $this->pageData['setup_database_creation']['skip_validation']
|
||||
)
|
||||
) {
|
||||
$backend = new DbUserBackend(
|
||||
ResourceFactory::createResource(new Zend_Config($this->pageData['setup_db_resource']))
|
||||
);
|
||||
|
||||
if (array_search($this->pageData['setup_admin_account']['new_user'], $backend->listUsers()) === false) {
|
||||
$backend->addUser(
|
||||
$this->pageData['setup_admin_account']['new_user'],
|
||||
$this->pageData['setup_admin_account']['new_user_password']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see Installer::getSummary()
|
||||
*/
|
||||
public function getSummary()
|
||||
{
|
||||
$summary = $this->pageData;
|
||||
if (isset($this->pageData['setup_db_resource'])) {
|
||||
$resourceConfig = $this->pageData['setup_db_resource'];
|
||||
if (isset($this->pageData['setup_database_creation'])) {
|
||||
$resourceConfig['username'] = $this->pageData['setup_database_creation']['username'];
|
||||
$resourceConfig['password'] = $this->pageData['setup_database_creation']['password'];
|
||||
}
|
||||
|
||||
$db = new DbTool($resourceConfig);
|
||||
try {
|
||||
$db->connectToDb();
|
||||
if (array_search('account', $db->listTables()) === false) {
|
||||
$message = sprintf(
|
||||
t(
|
||||
'The database user "%s" will be used to setup the missing'
|
||||
. ' schema required by Icinga Web 2 in database "%s".'
|
||||
),
|
||||
$resourceConfig['username'],
|
||||
$resourceConfig['dbname']
|
||||
);
|
||||
} else {
|
||||
$message = sprintf(
|
||||
t('The database "%s" already seems to be fully set up. No action required.'),
|
||||
$resourceConfig['dbname']
|
||||
);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
try {
|
||||
$db->connectToHost();
|
||||
if ($db->hasLogin($this->pageData['setup_db_resource']['username'])) {
|
||||
$message = sprintf(
|
||||
t(
|
||||
'The database user "%s" will be used to create the missing '
|
||||
. 'database "%s" with the schema required by Icinga Web 2.'
|
||||
),
|
||||
$resourceConfig['username'],
|
||||
$resourceConfig['dbname']
|
||||
);
|
||||
} else {
|
||||
$message = sprintf(
|
||||
t(
|
||||
'The database user "%s" will be used to create the missing database "%s" '
|
||||
. 'with the schema required by Icinga Web 2 and a new login called "%s".'
|
||||
),
|
||||
$resourceConfig['username'],
|
||||
$resourceConfig['dbname'],
|
||||
$this->pageData['setup_db_resource']['username']
|
||||
);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
$message = t(
|
||||
'No connection to database host possible. You\'ll need to setup the'
|
||||
. ' database with the schema required by Icinga Web 2 manually.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$summary['database_info'] = $message;
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see Installer::getReport()
|
||||
*/
|
||||
public function getReport()
|
||||
{
|
||||
return $this->report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the report
|
||||
*
|
||||
* @param string $message The message to add
|
||||
* @param bool $success Whether the message represents a success (true) or a failure (false)
|
||||
*/
|
||||
protected function log($message, $success = true)
|
||||
{
|
||||
$this->report[] = (object) array(
|
||||
'state' => (bool) $success,
|
||||
'message' => $message
|
||||
);
|
||||
}
|
||||
}
|
|
@ -18,10 +18,15 @@ use Icinga\Form\Setup\RequirementsPage;
|
|||
use Icinga\Form\Setup\GeneralConfigPage;
|
||||
use Icinga\Form\Setup\AuthenticationPage;
|
||||
use Icinga\Form\Setup\DatabaseCreationPage;
|
||||
use Icinga\Application\Installation\DatabaseStep;
|
||||
use Icinga\Application\Installation\GeneralConfigStep;
|
||||
use Icinga\Application\Installation\ResourceStep;
|
||||
use Icinga\Application\Installation\AuthenticationStep;
|
||||
use Icinga\Web\Form;
|
||||
use Icinga\Web\Wizard;
|
||||
use Icinga\Web\Request;
|
||||
use Icinga\Web\Setup\DbTool;
|
||||
use Icinga\Web\Setup\Installer;
|
||||
use Icinga\Web\Setup\SetupWizard;
|
||||
use Icinga\Web\Setup\Requirements;
|
||||
|
||||
|
@ -221,7 +226,74 @@ class WebSetup extends Wizard implements SetupWizard
|
|||
*/
|
||||
public function getInstaller()
|
||||
{
|
||||
return new WebInstaller($this->getPageData());
|
||||
$pageData = $this->getPageData();
|
||||
$installer = new Installer();
|
||||
|
||||
if (isset($pageData['setup_db_resource'])
|
||||
&& ! $pageData['setup_db_resource']['skip_validation']
|
||||
&& (false === isset($pageData['setup_database_creation'])
|
||||
|| ! $pageData['setup_database_creation']['skip_validation']
|
||||
)
|
||||
) {
|
||||
$installer->addStep(
|
||||
new DatabaseStep(array(
|
||||
'resourceConfig' => $pageData['setup_db_resource'],
|
||||
'adminName' => isset($pageData['setup_database_creation']['username'])
|
||||
? $pageData['setup_database_creation']['username']
|
||||
: null,
|
||||
'adminPassword' => isset($pageData['setup_database_creation']['password'])
|
||||
? $pageData['setup_database_creation']['password']
|
||||
: null
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
$installer->addStep(
|
||||
new GeneralConfigStep(array(
|
||||
'generalConfig' => $pageData['setup_general_config'],
|
||||
'preferencesType' => $pageData['setup_preferences_type']['type'],
|
||||
'preferencesResource' => $pageData['setup_db_resource']['name'],
|
||||
'fileMode' => $pageData['setup_general_config']['global_filemode']
|
||||
))
|
||||
);
|
||||
|
||||
if (isset($pageData['setup_db_resource']) || isset($pageData['setup_ldap_resource'])) {
|
||||
$installer->addStep(
|
||||
new ResourceStep(array(
|
||||
'fileMode' => $pageData['setup_general_config']['global_filemode'],
|
||||
'dbResourceConfig' => isset($pageData['setup_db_resource'])
|
||||
? array_diff_key($pageData['setup_db_resource'], array('skip_validation' => null))
|
||||
: null,
|
||||
'ldapResourceConfig' => isset($pageData['setup_ldap_resource'])
|
||||
? array_diff_key($pageData['setup_ldap_resource'], array('skip_validation' => null))
|
||||
: null
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
$adminAccountType = $pageData['setup_admin_account']['user_type'];
|
||||
$adminAccountData = array('username' => $pageData['setup_admin_account'][$adminAccountType]);
|
||||
if ($adminAccountType === 'new_user' && ! $pageData['setup_db_resource']['skip_validation']
|
||||
&& (false === isset($pageData['setup_database_creation'])
|
||||
|| ! $pageData['setup_database_creation']['skip_validation']
|
||||
)
|
||||
) {
|
||||
$adminAccountData['resourceConfig'] = $pageData['setup_db_resource'];
|
||||
$adminAccountData['password'] = $pageData['setup_admin_account']['new_user_password'];
|
||||
}
|
||||
$authType = $pageData['setup_authentication_type']['type'];
|
||||
$installer->addStep(
|
||||
new AuthenticationStep(array(
|
||||
'adminAccountData' => $adminAccountData,
|
||||
'fileMode' => $pageData['setup_general_config']['global_filemode'],
|
||||
'backendConfig' => $pageData['setup_authentication_backend'],
|
||||
'resourceName' => $authType === 'db' ? $pageData['setup_db_resource']['name'] : (
|
||||
$authType === 'ldap' ? $pageData['setup_ldap_resource']['name'] : null
|
||||
)
|
||||
))
|
||||
);
|
||||
|
||||
return $installer;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
// {{{ICINGA_LICENSE_HEADER}}}
|
||||
// {{{ICINGA_LICENSE_HEADER}}}
|
||||
|
||||
namespace Icinga\Exception;
|
||||
|
||||
/**
|
||||
* Class InstallException
|
||||
*
|
||||
* Used to indicate that a installation should be aborted.
|
||||
*/
|
||||
class InstallException extends IcingaException
|
||||
{
|
||||
}
|
|
@ -4,29 +4,93 @@
|
|||
|
||||
namespace Icinga\Web\Setup;
|
||||
|
||||
use ArrayIterator;
|
||||
use IteratorAggregate;
|
||||
use Icinga\Exception\InstallException;
|
||||
|
||||
/**
|
||||
* Interface for installers providing a summary and action report
|
||||
* Container for multiple installation steps
|
||||
*/
|
||||
interface Installer
|
||||
class Installer implements IteratorAggregate
|
||||
{
|
||||
protected $steps;
|
||||
|
||||
protected $state;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->steps = array();
|
||||
}
|
||||
|
||||
public function getIterator()
|
||||
{
|
||||
return new ArrayIterator($this->getSteps());
|
||||
}
|
||||
|
||||
public function addStep(Step $step)
|
||||
{
|
||||
$this->steps[] = $step;
|
||||
}
|
||||
|
||||
public function addSteps(array $steps)
|
||||
{
|
||||
foreach ($steps as $step) {
|
||||
$this->addStep($step);
|
||||
}
|
||||
}
|
||||
|
||||
public function getSteps()
|
||||
{
|
||||
return $this->steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the installation and return whether it succeeded
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function run();
|
||||
public function run()
|
||||
{
|
||||
$this->state = true;
|
||||
|
||||
try {
|
||||
foreach ($this->steps as $step) {
|
||||
$this->state &= $step->apply();
|
||||
}
|
||||
} catch (InstallException $e) {
|
||||
$this->state = false;
|
||||
}
|
||||
|
||||
return $this->state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a summary of all actions designated to run
|
||||
*
|
||||
* @return array
|
||||
* @return array An array of HTML strings
|
||||
*/
|
||||
public function getSummary();
|
||||
public function getSummary()
|
||||
{
|
||||
$summaries = array();
|
||||
foreach ($this->steps as $step) {
|
||||
$summaries[] = $step->getSummary();
|
||||
}
|
||||
|
||||
return $summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a report of all actions that were run
|
||||
*
|
||||
* @return array
|
||||
* @return array An array of HTML strings
|
||||
*/
|
||||
public function getReport();
|
||||
public function getReport()
|
||||
{
|
||||
$reports = array();
|
||||
foreach ($this->steps as $step) {
|
||||
$reports[] = $step->getReport();
|
||||
}
|
||||
|
||||
return $reports;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
// {{{ICINGA_LICENSE_HEADER}}}
|
||||
// {{{ICINGA_LICENSE_HEADER}}}
|
||||
|
||||
namespace Icinga\Web\Setup;
|
||||
|
||||
/**
|
||||
* Class to implement functionality for a single installation step
|
||||
*/
|
||||
abstract class Step
|
||||
{
|
||||
/**
|
||||
* Apply this step's installation changes
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
abstract public function apply();
|
||||
|
||||
/**
|
||||
* Return a HTML representation of this step's installation changes supposed to be made
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
abstract public function getSummary();
|
||||
|
||||
/**
|
||||
* Return a HTML representation of this step's installation changes that were made
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
abstract public function getReport();
|
||||
}
|
|
@ -252,6 +252,13 @@
|
|||
border-radius: 2em;
|
||||
background-color: #eee;
|
||||
|
||||
div.line-separator {
|
||||
width: 50%;
|
||||
height: 1px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 1em;
|
||||
color: #444;
|
||||
|
|
Loading…
Reference in New Issue