From 78f843916da3846cc56f764a6ec206bb54782611 Mon Sep 17 00:00:00 2001 From: Katharina Irrgang Date: Thu, 6 Jul 2017 00:00:46 +0200 Subject: [PATCH] feat(mysql): mysql user creation extension (#269) refs #191 - adds initial iteration of mysql user extension - adds some config error modifications to output multiple config values --- extensions/mysql/index.js | 136 ++++++++++++++++++++++++++++++++++ extensions/mysql/package.json | 5 +- lib/errors.js | 19 ++++- package.json | 1 + yarn.lock | 2 +- 5 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 extensions/mysql/index.js diff --git a/extensions/mysql/index.js b/extensions/mysql/index.js new file mode 100644 index 000000000..a393df489 --- /dev/null +++ b/extensions/mysql/index.js @@ -0,0 +1,136 @@ +'use strict'; + +const Promise = require('bluebird'); +const mysql = require('mysql'); +const crypto = require('crypto'); +const omit = require('lodash/omit'); +const cli = require('../../lib'); + +class MySQLExtension extends cli.Extension { + _query(queryString) { + return Promise.fromCallback(cb => this.connection.query(queryString, cb)); + } + + setup(cmd, argv) { + // ghost setup --local, skip + if (argv.local) { + return; + } + + cmd.addStage('mysql', this.setupMySQL.bind(this)); + } + + setupMySQL(argv, ctx, task) { + this.databaseConfig = ctx.instance.config.get('database'); + + return this.canConnect(ctx) + .then(() => { + if (this.databaseConfig.connection.user === 'root') { + return this.ui.confirm('Your MySQL user is root. Would you like to create a custom Ghost MySQL user?', true) + .then((res) => { + if (res.yes) { + return this.createMySQLUser(ctx); + } + }); + } + + this.ui.log('MySQL: Your user is: ' + this.databaseConfig.connection.user, 'green'); + }) + .finally(() => { + this.connection.end(); + }); + } + + canConnect(ctx) { + this.connection = mysql.createConnection(omit(this.databaseConfig.connection, 'database')); + + return Promise.fromCallback(cb => this.connection.connect(cb)) + .then(() => { + this.ui.log('MySQL: connection successful.', 'green'); + }) + .catch((err) => { + this.ui.log('MySQL: connection error.', 'yellow'); + + if (err.code === 'ER_ACCESS_DENIED_ERROR') { + throw new cli.errors.ConfigError({ + message: err.message, + configs: { + 'database.connection.user': this.databaseConfig.connection.user, + 'database.connection.password': this.databaseConfig.connection.password + }, + environment: ctx.instance.system.environment, + help: 'You can run `ghost config` to re-enter the correct credentials. Alternatively you can run `ghost setup` again.' + }); + } + + throw new cli.errors.ConfigError({ + message: err.message, + configs: { + 'database.connection.host': this.databaseConfig.connection.host, + 'database.connection.port': this.databaseConfig.connection.port || '3306' + }, + environment: ctx.instance.system.environment, + help: 'Please ensure that MySQL is installed and reachable. You can always re-run `ghost setup` and try it again.' + }); + }); + } + + createMySQLUser(ctx) { + let randomPassword = crypto.randomBytes(10).toString('hex'); + let host = this.databaseConfig.connection.host; + + // IMPORTANT: we generate random MySQL usernames + // e.g. you delete all your Ghost instances from your droplet and start from scratch, the MySQL users would remain and the CLI has to generate a random user name to be able to + // e.g. if we would rely on the instance name, the instance naming only auto increments if there are existing instances + // the most important fact is, that if a MySQL user exists, we have no access to the password, which we need to autofill the Ghost config + // disadvantage: the CLI could potentially create lot's of MySQL users (but this should only happen if the user installs Ghost over and over again with root credentials) + let username = 'ghost-' + Math.floor(Math.random() * 1000); + + return this._query('CREATE USER \'' + username + '\'@\'' + host + '\' IDENTIFIED BY \'' + randomPassword + '\';') + .then(() => { + this.ui.log('MySQL: successfully created `' + username + '`.', 'green'); + + return this.grantPermissions({username: username}) + .then(() => { + ctx.instance.config.set('database.connection.user', username); + ctx.instance.config.set('database.connection.password', randomPassword); + }); + }) + .catch((err) => { + // CASE: user exists, we are not able to figure out the original password, skip mysql setup + if (err.errno === 1396) { + this.ui.log('MySQL: `' + username + '` user exists. Skipping.', 'yellow'); + return Promise.resolve(); + } + + this.ui.log('MySQL: unable to create custom Ghost user.', 'yellow'); + throw new cli.errors.SystemError(err.message); + }); + } + + grantPermissions(options) { + let host = this.databaseConfig.connection.host; + let database = this.databaseConfig.connection.database; + let username = options.username; + + return this._query('GRANT ALL PRIVILEGES ON ' + database + '.* TO \'' + username + '\'@\'' + host + '\';') + .then(() => { + this.ui.log('MySQL: successfully granted permissions for `' + username + '` user.', 'green'); + + return this._query('FLUSH PRIVILEGES;') + .then(() => { + this.ui.log('MySQL: flushed privileges', 'green'); + }) + .catch((err) => { + this.ui.log('MySQL: unable to flush privileges.', 'yellow'); + throw new cli.errors.SystemError(err.message); + }); + }) + .catch((err) => { + this.ui.log('MySQL: unable to grant permissions for `' + username + '` user.', 'yellow'); + throw new cli.errors.SystemError(err.message); + }); + } +} + +module.exports = MySQLExtension; diff --git a/extensions/mysql/package.json b/extensions/mysql/package.json index 4a7d43672..20b2cad43 100644 --- a/extensions/mysql/package.json +++ b/extensions/mysql/package.json @@ -1,8 +1,9 @@ { - "name": "ghost-cli-config-mysql", + "name": "ghost-cli-mysql", "version": "0.0.0", "description": "MySQL configuration handling for Ghost-CLI", "keywords": [ "ghost-cli-extension" - ] + ], + "main": "index.js" } diff --git a/lib/errors.js b/lib/errors.js index d1e09b867..0f6bab918 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -98,7 +98,8 @@ class ProcessError extends CliError { * @class GhostError * @extends CliError */ -class GhostError extends CliError {} +class GhostError extends CliError { +} /** * Handles all errors resulting from system issues @@ -133,10 +134,26 @@ class ConfigError extends CliError { let initial = chalk.red(`Error detected in the ${this.options.environment} configuration.\n\n`) + `${chalk.gray('Message:')} ${this.options.message}\n`; + // @TODO: merge this solution into one if (this.options.configKey) { initial += `${chalk.gray('Configuration Key:')} ${this.options.configKey}\n` + `${chalk.gray('Current Value:')} ${this.options.configValue}\n\n` + chalk.blue(`Run \`${chalk.underline(`ghost config ${this.options.configKey} `)}\` to fix it.\n`); + } else if (this.options.configs) { + initial += '\n'; + + for (const key in this.options.configs) { + if (this.options.configs.hasOwnProperty(key)) { + initial += `${chalk.gray('Configuration Key:')} ${key}\n` + + `${chalk.gray('Current Value:')} ${this.options.configs[key]}\n\n`; + } + } + + if (this.options.help) { + initial += '\n'; + initial += `${chalk.gray('Help:')} ${this.options.help}`; + initial += '\n'; + } } return initial; diff --git a/package.json b/package.json index 3bd7d8c27..d2edf5623 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "knex-migrator": "2.0.16", "listr": "0.12.0", "lodash": "4.17.4", + "mysql": "2.13.0", "nginx-conf": "1.3.0", "ora": "1.3.0", "portfinder": "1.0.13", diff --git a/yarn.lock b/yarn.lock index 399601567..8133724da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2449,7 +2449,7 @@ mv@~2: ncp "~2.0.0" rimraf "~2.4.0" -mysql@^2.11.1: +mysql@2.13.0, mysql@^2.11.1: version "2.13.0" resolved "https://registry.yarnpkg.com/mysql/-/mysql-2.13.0.tgz#998f1f8ca46e2e3dd7149ce982413653986aae47" dependencies: