Skip to content

Commit 0d07841

Browse files
authored
feat(import): add import functionality (#1043)
refs #468 - add `--from-export` argument to install command - add import command to import a ghost export file into an existing instance - add import parsing/loading code
1 parent ae1f9ce commit 0d07841

17 files changed

+786
-6
lines changed

lib/commands/import.js

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const Command = require('../command');
2+
3+
class ImportCommand extends Command {
4+
async run(argv) {
5+
const semver = require('semver');
6+
const importTask = require('../tasks/import');
7+
const {SystemError} = require('../errors');
8+
9+
const instance = this.system.getInstance();
10+
const {version} = importTask.parseExport(argv.file);
11+
12+
if (semver.major(version) === 0 && semver.major(instance.version) > 1) {
13+
throw new SystemError(`v0.x export files can only be imported by Ghost v1.x versions. You are running Ghost v${instance.version}.`);
14+
}
15+
16+
const isRunning = await instance.isRunning();
17+
18+
if (!isRunning) {
19+
const shouldStart = await this.ui.confirm('Ghost instance is not currently running. Would you like to start it?', true);
20+
21+
if (!shouldStart) {
22+
throw new SystemError('Ghost instance is not currently running');
23+
}
24+
25+
instance.checkEnvironment();
26+
await this.ui.run(() => instance.start(), 'Starting Ghost');
27+
}
28+
29+
const importTasks = await importTask(this.ui, instance, argv.file);
30+
await importTasks.run();
31+
}
32+
}
33+
34+
ImportCommand.description = 'Import a Ghost export';
35+
ImportCommand.params = '[file]';
36+
37+
module.exports = ImportCommand;

lib/commands/install.js

+23
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ class InstallCommand extends Command {
3636
this.system.setEnvironment(true, true);
3737
}
3838

39+
if (argv.fromExport) {
40+
const semver = require('semver');
41+
const {parseExport} = require('../tasks/import');
42+
const parsed = parseExport(argv.fromExport);
43+
44+
if (version) {
45+
this.ui.log('Warning: you specified both a specific version and an export file. The version specified in the export file will be used.', 'yellow');
46+
}
47+
48+
if (semver.major(parsed.version) === 0) {
49+
this.ui.log('Detected a v0.x export file. Installing latest v1.x version.', 'green');
50+
version = null;
51+
argv.v1 = true;
52+
} else {
53+
version = parsed.version;
54+
}
55+
}
56+
3957
return this.runCommand(DoctorCommand, Object.assign({
4058
categories: ['install'],
4159
skipInstanceCheck: true,
@@ -138,6 +156,11 @@ InstallCommand.options = {
138156
description: '[--no-setup] Enable/Disable auto-running the setup command',
139157
type: 'boolean',
140158
default: true
159+
},
160+
'from-export': {
161+
alias: 'f',
162+
description: 'Path to a Ghost export file to import after setup',
163+
type: 'string'
141164
}
142165
};
143166
InstallCommand.runPreChecks = true;

lib/commands/setup.js

+8-1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class SetupCommand extends Command {
5151
const linux = require('../tasks/linux');
5252
const migrator = require('../tasks/migrator');
5353
const configure = require('../tasks/configure');
54+
const importTask = require('../tasks/import');
5455

5556
// This is used so we can denote built-in setup steps
5657
// and disable the "do you wish to set up x?" prompt
@@ -102,14 +103,20 @@ class SetupCommand extends Command {
102103
return true;
103104
}
104105

105-
if (argv.start) {
106+
if (argv.start || argv.fromExport) {
106107
return false;
107108
}
108109

109110
const confirmed = await ui.confirm('Do you want to start Ghost?', true);
110111
return !confirmed;
111112
},
112113
task: ({instance, argv}) => instance.start(argv.enable)
114+
}, {
115+
id: 'import',
116+
title: 'Importing content',
117+
[internal]: true,
118+
enabled: ({argv}) => Boolean(argv.fromExport),
119+
task: ({ui, instance, argv}) => importTask(ui, instance, argv.fromExport)
113120
}].map((step) => {
114121
const name = step.name || step.id;
115122
const title = step.title || `Setting up ${name}`;

lib/tasks/import/api.js

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
const fs = require('fs-extra');
2+
const got = require('got');
3+
const get = require('lodash/get');
4+
const semver = require('semver');
5+
const FormData = require('form-data');
6+
const {Cookie} = require('tough-cookie');
7+
8+
const {SystemError} = require('../../errors');
9+
10+
const bases = {
11+
1: '/ghost/api/v0.1',
12+
2: '/ghost/api/v2/admin',
13+
3: '/ghost/api/v3/admin'
14+
};
15+
16+
function getBaseUrl(version, url) {
17+
const basePath = bases[semver.major(version)];
18+
19+
if (!basePath) {
20+
throw new SystemError(`Unsupported version: ${version}`);
21+
}
22+
23+
return `${url.replace(/\/?$/, '')}${basePath}`;
24+
}
25+
26+
async function isSetup(version, url) {
27+
const baseUrl = getBaseUrl(version, url);
28+
const {body} = await got('/authentication/setup/', {baseUrl, json: true});
29+
return get(body, 'setup[0].status', false);
30+
}
31+
32+
async function setup(version, url, data) {
33+
const baseUrl = getBaseUrl(version, url);
34+
const {name, email, password, blogTitle} = data;
35+
const body = {
36+
setup: [{name, email, password, blogTitle}]
37+
};
38+
39+
await got.post('/authentication/setup/', {baseUrl, body, json: true});
40+
}
41+
42+
async function getAuthOpts(version, url, {username, password}) {
43+
const baseUrl = getBaseUrl(version, url);
44+
45+
if (semver.major(version) === 1) {
46+
const {body: configBody} = await got('/configuration/', {baseUrl, json: true});
47+
const {clientId, clientSecret} = get(configBody, 'configuration[0]', {});
48+
const {body: authBody} = await got.post('/authentication/token/', {
49+
baseUrl,
50+
json: true,
51+
form: true,
52+
body: {
53+
grant_type: 'password',
54+
client_id: clientId,
55+
client_secret: clientSecret,
56+
username,
57+
password
58+
}
59+
});
60+
61+
return {
62+
baseUrl,
63+
headers: {
64+
Authorization: `Bearer ${authBody.access_token}`
65+
}
66+
};
67+
}
68+
69+
const {headers} = await got.post('/session/', {
70+
baseUrl,
71+
headers: {
72+
Origin: url,
73+
'Content-Type': 'application/json'
74+
},
75+
body: JSON.stringify({username, password})
76+
});
77+
78+
/* istanbul ignore next */
79+
const cookies = headers['set-cookie'] || [];
80+
const filteredCookies = cookies.map(Cookie.parse).filter(Boolean).map(c => c.cookieString());
81+
82+
return {
83+
baseUrl,
84+
headers: {
85+
Origin: url,
86+
Cookie: filteredCookies
87+
}
88+
};
89+
}
90+
91+
async function runImport(version, url, auth, exportFile) {
92+
const authOpts = await getAuthOpts(version, url, auth);
93+
const body = new FormData();
94+
95+
body.append('importfile', fs.createReadStream(exportFile));
96+
await got.post('/db/', {...authOpts, body});
97+
}
98+
99+
module.exports = {
100+
getBaseUrl,
101+
isSetup,
102+
setup,
103+
runImport
104+
};

lib/tasks/import/index.js

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
const validator = require('validator');
2+
3+
const parseExport = require('./parse-export');
4+
const {isSetup, setup, runImport} = require('./api');
5+
6+
async function task(ui, instance, exportFile) {
7+
const {data} = parseExport(exportFile);
8+
const url = instance.config.get('url');
9+
10+
const prompts = [{
11+
type: 'password',
12+
name: 'password',
13+
message: 'Enter your Ghost administrator password',
14+
validate: val => validator.isLength(`${val}`, {min: 10}) || 'Password must be at least 10 characters long'
15+
}];
16+
17+
const blogIsSetup = await isSetup(instance.version, url);
18+
if (blogIsSetup) {
19+
prompts.unshift({
20+
type: 'string',
21+
name: 'username',
22+
message: 'Enter your Ghost administrator email address',
23+
validate: val => validator.isEmail(`${val}`) || 'You must specify a valid email'
24+
});
25+
}
26+
27+
const {username, password} = await ui.prompt(prompts);
28+
const importUsername = username || data.email;
29+
30+
return ui.listr([{
31+
title: 'Running blog setup',
32+
task: () => setup(instance.version, url, {...data, password}),
33+
enabled: () => !blogIsSetup
34+
}, {
35+
title: 'Running blog import',
36+
task: () => runImport(instance.version, url, {username: importUsername, password}, exportFile)
37+
}], false);
38+
}
39+
40+
module.exports = task;
41+
module.exports.parseExport = parseExport;

lib/tasks/import/parse-export.js

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const fs = require('fs-extra');
2+
const get = require('lodash/get');
3+
const find = require('lodash/find');
4+
const semver = require('semver');
5+
6+
const {SystemError} = require('../../errors');
7+
8+
const pre1xVersion = /^00[1-9]$/;
9+
10+
/* eslint-disable camelcase */
11+
function parse(content) {
12+
const data = get(content, 'db[0].data', content.data || null);
13+
/* istanbul ignore next */
14+
const {id: role_id} = find(data.roles, {name: 'Owner'}) || {};
15+
/* istanbul ignore next */
16+
const {user_id} = find(data.roles_users, {role_id}) || {};
17+
/* istanbul ignore next */
18+
const {name, email} = find(data.users, {id: user_id}) || {};
19+
/* istanbul ignore next */
20+
const {value: blogTitle} = find(data.settings, {key: 'title'}) || {};
21+
22+
return {name, email, blogTitle};
23+
}
24+
/* eslint-enable camelcase */
25+
26+
module.exports = function parseExport(file) {
27+
let content = {};
28+
29+
try {
30+
content = fs.readJsonSync(file);
31+
} catch (err) {
32+
throw new SystemError({
33+
message: 'Import file not found or is not valid JSON',
34+
err
35+
});
36+
}
37+
38+
const version = get(content, 'db[0].meta.version', get(content, 'meta.version', null));
39+
if (!version) {
40+
throw new SystemError('Unable to determine export version');
41+
}
42+
43+
const validVersion = pre1xVersion.test(version) || semver.valid(version);
44+
if (!validVersion) {
45+
throw new SystemError(`Unrecognized export version: ${version}`);
46+
}
47+
48+
const data = parse(content);
49+
return {
50+
version: pre1xVersion.test(version) ? '0.11.14' : version,
51+
data
52+
};
53+
};

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"execa": "1.0.0",
5858
"find-plugins": "1.1.7",
5959
"fkill": "6.2.0",
60+
"form-data": "2.5.1",
6061
"fs-extra": "8.1.0",
6162
"generate-password": "1.4.2",
6263
"global-modules": "2.0.0",
@@ -84,6 +85,7 @@
8485
"symlink-or-copy": "1.2.0",
8586
"systeminformation": "4.14.17",
8687
"tail": "2.0.3",
88+
"tough-cookie": "3.0.1",
8789
"validator": "7.2.0",
8890
"yargs": "14.2.0",
8991
"yarn": "1.19.1"
@@ -95,6 +97,7 @@
9597
"eslint-plugin-ghost": "0.6.0",
9698
"has-ansi": "4.0.0",
9799
"mocha": "6.2.2",
100+
"nock": "11.7.0",
98101
"nyc": "14.1.1",
99102
"proxyquire": "2.1.3",
100103
"sinon": "7.5.0",

0 commit comments

Comments
 (0)