diff --git a/packages/core/data-transfer/src/strapi/__tests__/register.test.ts b/packages/core/data-transfer/src/strapi/__tests__/register.test.ts index dc739fb52d..9063b60c19 100644 --- a/packages/core/data-transfer/src/strapi/__tests__/register.test.ts +++ b/packages/core/data-transfer/src/strapi/__tests__/register.test.ts @@ -2,6 +2,7 @@ import { getStrapiFactory } from '../../__tests__/test-utils'; import { createTransferHandler } from '../remote/handlers'; import register from '../register'; +import { TRANSFER_PATH } from '../../../lib/strapi/remote/constants'; afterEach(() => { jest.clearAllMocks(); @@ -20,13 +21,13 @@ jest.mock('../remote/handlers', () => ({ })); describe('Register the Transfer route', () => { - test('registers the /transfer route', () => { + test('registers the transfer route', () => { const strapi = strapiMockFactory(); register(strapi); expect(strapi.admin.routes.push).toHaveBeenCalledWith({ method: 'GET', - path: '/transfer', + path: TRANSFER_PATH, handler: createTransferHandler(), config: { auth: false, diff --git a/packages/core/data-transfer/src/strapi/providers/remote-destination/__tests__/utils.test.ts b/packages/core/data-transfer/src/strapi/providers/remote-destination/__tests__/utils.test.ts index 8c907c756b..4f60192a56 100644 --- a/packages/core/data-transfer/src/strapi/providers/remote-destination/__tests__/utils.test.ts +++ b/packages/core/data-transfer/src/strapi/providers/remote-destination/__tests__/utils.test.ts @@ -1,4 +1,5 @@ import { WebSocket } from 'ws'; +import { TRANSFER_PATH } from '../../../../../lib/strapi/remote/constants'; import { CommandMessage } from '../../../../../types/remote/protocol/client'; import { createDispatcher } from '../utils'; @@ -18,7 +19,7 @@ afterEach(() => { describe('Remote Strapi Destination Utils', () => { test('Dispatch method sends payload', () => { - const ws = new WebSocket('ws://test/admin/transfer'); + const ws = new WebSocket(`ws://test/admin${TRANSFER_PATH}`); const message: CommandMessage = { type: 'command', command: 'status', diff --git a/packages/core/data-transfer/src/strapi/providers/remote-destination/index.ts b/packages/core/data-transfer/src/strapi/providers/remote-destination/index.ts index de1eb3166b..ec40352106 100644 --- a/packages/core/data-transfer/src/strapi/providers/remote-destination/index.ts +++ b/packages/core/data-transfer/src/strapi/providers/remote-destination/index.ts @@ -15,6 +15,7 @@ import type { } from '../../../../types'; import type { client, server } from '../../../../types/remote/protocol'; import type { ILocalStrapiDestinationProviderOptions } from '../local-destination'; +import { TRANSFER_PATH } from '../../remote/constants'; interface ITokenAuth { type: 'token'; @@ -29,7 +30,7 @@ interface ICredentialsAuth { export interface IRemoteStrapiDestinationProviderOptions extends Pick { - url: string; + url: URL; auth?: ITokenAuth | ICredentialsAuth; } @@ -100,16 +101,19 @@ class RemoteStrapiDestinationProvider implements IDestinationProvider { let ws: WebSocket; + const wsUrl = `${url.protocol === 'https:' ? 'wss:' : 'ws:'}//${url.host}${ + url.pathname + }${TRANSFER_PATH}`; + // No auth defined, trying public access for transfer if (!auth) { - ws = new WebSocket(url); + ws = new WebSocket(wsUrl); } // Common token auth, this should be the main auth method else if (auth.type === 'token') { - const headers = { Authentication: `Bearer ${auth.token}` }; - - ws = new WebSocket(this.options.url, { headers }); + const headers = { Authorization: `Bearer ${auth.token}` }; + ws = new WebSocket(wsUrl, { headers }); } // Invalid auth method provided diff --git a/packages/core/data-transfer/src/strapi/remote/constants.ts b/packages/core/data-transfer/src/strapi/remote/constants.ts index 6f913eeba6..536be055a2 100644 --- a/packages/core/data-transfer/src/strapi/remote/constants.ts +++ b/packages/core/data-transfer/src/strapi/remote/constants.ts @@ -1 +1 @@ -export const TRANSFER_URL = '/transfer'; +export const TRANSFER_PATH = '/transfer'; diff --git a/packages/core/data-transfer/src/strapi/remote/routes.ts b/packages/core/data-transfer/src/strapi/remote/routes.ts index 746bed09bb..0c26555ad0 100644 --- a/packages/core/data-transfer/src/strapi/remote/routes.ts +++ b/packages/core/data-transfer/src/strapi/remote/routes.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line node/no-extraneous-import import type { Context } from 'koa'; -import { TRANSFER_URL } from './constants'; +import { TRANSFER_PATH } from './constants'; import { createTransferHandler } from './handlers'; // Extend Strapi interface type to access the admin routes' API @@ -29,7 +29,7 @@ declare module '@strapi/strapi' { export const registerAdminTransferRoute = (strapi: Strapi.Strapi) => { strapi.admin.routes.push({ method: 'GET', - path: TRANSFER_URL, + path: TRANSFER_PATH, handler: createTransferHandler(), config: { auth: false }, }); diff --git a/packages/core/strapi/bin/strapi.js b/packages/core/strapi/bin/strapi.js index 757aead554..7a3af552ab 100755 --- a/packages/core/strapi/bin/strapi.js +++ b/packages/core/strapi/bin/strapi.js @@ -14,7 +14,12 @@ const inquirer = require('inquirer'); const program = new Command(); const packageJSON = require('../package.json'); -const { promptEncryptionKey, confirmMessage } = require('../lib/commands/utils/commander'); +const { + promptEncryptionKey, + confirmMessage, + parseURL, +} = require('../lib/commands/utils/commander'); +const { ifOptions, assertUrlHasProtocol, exitWith } = require('../lib/commands/utils/helpers'); const checkCwdIsStrapiApp = (name) => { const logErrorAndExit = () => { @@ -263,16 +268,39 @@ if (process.env.STRAPI_EXPERIMENTAL) { program .command('transfer') .description('Transfer data from one source to another') - .addOption(new Option('--from ', `URL of remote Strapi instance to get data from.`)) - .addOption(new Option('--to ', `URL of remote Strapi instance to send data to`)) - .hook('preAction', async (thisCommand) => { - const opts = thisCommand.opts(); - - if (!opts.from && !opts.to) { - console.error('At least one source (from) or destination (to) option must be provided'); - process.exit(1); - } - }) + .addOption( + new Option('--from ', `URL of remote Strapi instance to get data from.`).argParser( + parseURL + ) + ) + .addOption( + new Option( + '--to ', + `URL of remote Strapi instance to send data to` + ).argParser(parseURL) + ) + // Validate URLs + .hook( + 'preAction', + ifOptions( + (opts) => opts.from, + (thisCommand) => assertUrlHasProtocol(thisCommand.opts().from, ['https:', 'http:']) + ) + ) + .hook( + 'preAction', + ifOptions( + (opts) => opts.to, + (thisCommand) => assertUrlHasProtocol(thisCommand.opts().to, ['https:', 'http:']) + ) + ) + .hook( + 'preAction', + ifOptions( + (opts) => !opts.from && !opts.to, + () => exitWith(1, 'At least one source (from) or destination (to) option must be provided') + ) + ) .allowExcessArguments(false) .action(getLocalScript('transfer/transfer')); } diff --git a/packages/core/strapi/lib/commands/__tests__/data-transfer/transfer.test.js b/packages/core/strapi/lib/commands/__tests__/data-transfer/transfer.test.js index 3ae7131ffd..88904042e1 100644 --- a/packages/core/strapi/lib/commands/__tests__/data-transfer/transfer.test.js +++ b/packages/core/strapi/lib/commands/__tests__/data-transfer/transfer.test.js @@ -38,7 +38,7 @@ jest.spyOn(console, 'log').mockImplementation(() => {}); jest.mock('../../transfer/utils'); -const destinationUrl = 'ws://strapi.com'; +const destinationUrl = new URL('http://strapi.com/admin'); describe('Transfer', () => { beforeEach(() => { diff --git a/packages/core/strapi/lib/commands/transfer/transfer.js b/packages/core/strapi/lib/commands/transfer/transfer.js index eacea973d9..d10d650207 100644 --- a/packages/core/strapi/lib/commands/transfer/transfer.js +++ b/packages/core/strapi/lib/commands/transfer/transfer.js @@ -22,8 +22,8 @@ const logger = console; /** * @typedef TransferCommandOptions Options given to the CLI transfer command * - * @property {string|undefined} [to] The url of a remote Strapi to use as remote destination - * @property {string|undefined} [from] The url of a remote Strapi to use as remote source + * @property {URL|undefined} [to] The url of a remote Strapi to use as remote destination + * @property {URL|undefined} [from] The url of a remote Strapi to use as remote source */ /** @@ -86,8 +86,8 @@ module.exports = async (opts) => { } const engine = createTransferEngine(source, destination, { - versionStrategy: 'ignore', // for an export to file, versionStrategy will always be skipped - schemaStrategy: 'ignore', // for an export to file, schemaStrategy will always be skipped + versionStrategy: 'exact', // for an export to file, versionStrategy will always be skipped + schemaStrategy: 'exact', // for an export to file, schemaStrategy will always be skipped transforms: { links: [ { diff --git a/packages/core/strapi/lib/commands/transfer/utils.js b/packages/core/strapi/lib/commands/transfer/utils.js index bdab314200..48ccb10c25 100644 --- a/packages/core/strapi/lib/commands/transfer/utils.js +++ b/packages/core/strapi/lib/commands/transfer/utils.js @@ -2,7 +2,7 @@ const chalk = require('chalk'); const Table = require('cli-table3'); -const { readableBytes } = require('../utils'); +const { readableBytes } = require('../utils/helpers'); const strapi = require('../../index'); const pad = (n) => { diff --git a/packages/core/strapi/lib/commands/utils/commander.js b/packages/core/strapi/lib/commands/utils/commander.js index 8c9ce354d4..72153c05bb 100644 --- a/packages/core/strapi/lib/commands/utils/commander.js +++ b/packages/core/strapi/lib/commands/utils/commander.js @@ -1,14 +1,36 @@ 'use strict'; +/** + * This file includes hooks to use for commander.hook and argParsers for commander.argParser + */ + const inquirer = require('inquirer'); +const { InvalidOptionArgumentError } = require('commander'); +const { exitWith } = require('./helpers'); /** - * argsParser: Parse a comma-delimited string as an array + * argParser: Parse a comma-delimited string as an array */ const parseInputList = (value) => { return value.split(','); }; +/** + * argParser: Parse a string as a URL object + */ +const parseURL = (value) => { + try { + const url = new URL(value); + if (!url.host) { + throw new InvalidOptionArgumentError(`Could not parse url ${value}`); + } + + return url; + } catch (e) { + throw new InvalidOptionArgumentError(`Could not parse url ${value}`); + } +}; + /** * hook: if encrypt==true and key not provided, prompt for it */ @@ -16,8 +38,7 @@ const promptEncryptionKey = async (thisCommand) => { const opts = thisCommand.opts(); if (!opts.encrypt && opts.key) { - console.error('Key may not be present unless encryption is used'); - process.exit(1); + return exitWith(1, 'Key may not be present unless encryption is used'); } // if encrypt==true but we have no key, prompt for it @@ -37,12 +58,10 @@ const promptEncryptionKey = async (thisCommand) => { ]); opts.key = answers.key; } catch (e) { - console.error('Failed to get encryption key'); - process.exit(1); + return exitWith(1, 'Failed to get encryption key'); } if (!opts.key) { - console.error('Failed to get encryption key'); - process.exit(1); + return exitWith(1, 'Failed to get encryption key'); } } }; @@ -61,13 +80,15 @@ const confirmMessage = (message) => { }, ]); if (!answers.confirm) { - process.exit(0); + exitWith(0); } }; }; module.exports = { parseInputList, + parseURL, promptEncryptionKey, confirmMessage, + exitWith, }; diff --git a/packages/core/strapi/lib/commands/utils/helpers.js b/packages/core/strapi/lib/commands/utils/helpers.js new file mode 100644 index 0000000000..997c3c6071 --- /dev/null +++ b/packages/core/strapi/lib/commands/utils/helpers.js @@ -0,0 +1,108 @@ +'use strict'; + +/** + * Helper functions for the Strapi CLI + */ + +const chalk = require('chalk'); +const { isString, isArray } = require('lodash/fp'); + +const bytesPerKb = 1024; +const sizes = ['B ', 'KB', 'MB', 'GB', 'TB', 'PB']; + +/** + * Convert bytes to a human readable formatted string, for example "1024" becomes "1KB" + * + * @param {number} bytes The bytes to be converted + * @param {number} decimals How many decimals to include in the final number + * @param {number} padStart Pad the string with space at the beginning so that it has at least this many characters + */ +const readableBytes = (bytes, decimals = 1, padStart = 0) => { + if (!bytes) { + return '0'; + } + const i = Math.floor(Math.log(bytes) / Math.log(bytesPerKb)); + const result = `${parseFloat((bytes / bytesPerKb ** i).toFixed(decimals))} ${sizes[i].padStart( + 2 + )}`; + + return result.padStart(padStart); +}; + +/** + * + * Display message(s) to console and then call process.exit with code. + * If code is zero, console.log and green text is used for messages, otherwise console.error and red text. + * + * @param {number} code Code to exit process with + * @param {string | Array} message Message(s) to display before exiting + */ +const exitWith = (code, message = undefined) => { + const logger = (message) => { + if (code === 0) { + console.log(chalk.green(message)); + } else { + console.log(chalk.red(message)); + } + }; + + if (isString(message)) { + logger(message); + } else if (isArray(message)) { + message.forEach((msg) => logger(msg)); + } + process.exit(code); +}; + +/** + * assert that a URL object has a protocol value + * + * @param {URL} url + * @param {string[]|string|undefined} [protocol] + */ +const assertUrlHasProtocol = (url, protocol = undefined) => { + if (!url.protocol) { + exitWith(1, `${url.toString()} does not have a protocol`); + } + + // if just checking for the existence of a protocol, return + if (!protocol) { + return; + } + + if (isString(protocol)) { + if (protocol !== url.protocol) { + exitWith(1, `${url.toString()} must have the protocol ${protocol}`); + } + return; + } + + // assume an array + if (!protocol.some((protocol) => url.protocol === protocol)) { + return exitWith( + 1, + `${url.toString()} must have one of the following protocols: ${protocol.join(',')}` + ); + } +}; + +/** + * Passes commander options to conditionCallback(). If it returns true, call isMetCallback otherwise call isNotMetCallback + */ +const ifOptions = (conditionCallback, isMetCallback = () => {}, isNotMetCallback = () => {}) => { + return async (command) => { + const opts = command.opts(); + if (await conditionCallback(opts)) { + await isMetCallback(command); + } else { + await isNotMetCallback(command); + } + }; +}; + +module.exports = { + exitWith, + assertUrlHasProtocol, + ifOptions, + readableBytes, +}; diff --git a/packages/core/strapi/lib/commands/utils/index.js b/packages/core/strapi/lib/commands/utils/index.js deleted file mode 100644 index fab9516c41..0000000000 --- a/packages/core/strapi/lib/commands/utils/index.js +++ /dev/null @@ -1,20 +0,0 @@ -'use strict'; - -const bytesPerKb = 1024; -const sizes = ['B ', 'KB', 'MB', 'GB', 'TB', 'PB']; - -const readableBytes = (bytes, decimals = 1, padStart = 0) => { - if (!bytes) { - return '0'; - } - const i = Math.floor(Math.log(bytes) / Math.log(bytesPerKb)); - const result = `${parseFloat((bytes / bytesPerKb ** i).toFixed(decimals))} ${sizes[i].padStart( - 2 - )}`; - - return result.padStart(padStart); -}; - -module.exports = { - readableBytes, -};