Merge branch 'features/deits' into deits/import-assets

This commit is contained in:
Christian Capeans 2022-12-12 16:56:29 +01:00
commit e177ac32a0
27 changed files with 344 additions and 141 deletions

View File

@ -0,0 +1,19 @@
'use strict';
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.eslint.json',
},
plugins: ['@typescript-eslint'],
// TODO: This should be turned on but causes hundreds of violations in .d.ts files throughout Strapi that would need to be fixed
// extends: ['@strapi/eslint-config/typescript'],
globals: {
strapi: false,
},
// Instead of extending (which includes values that interfere with this configuration), only take the rules field
rules: {
...require('./.eslintrc.back.js').rules,
'import/no-extraneous-dependencies': ['error', { devDependencies: ['**/*.test.ts'] }],
},
};

View File

@ -2,11 +2,11 @@
module.exports = { module.exports = {
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.eslint.json',
},
plugins: ['@typescript-eslint'], plugins: ['@typescript-eslint'],
/** extends: ['@strapi/eslint-config/typescript'],
* TODO: this should extend @strapi/eslint-config/typescript but doing so requires configuring parserOption.project, which requires tsconfig.json configuration
*/
// extends: ['plugin:@typescript-eslint/recommended'],
globals: { globals: {
strapi: false, strapi: false,
}, },
@ -14,5 +14,22 @@ module.exports = {
rules: { rules: {
...require('./.eslintrc.back.js').rules, ...require('./.eslintrc.back.js').rules,
'import/no-extraneous-dependencies': ['error', { devDependencies: ['**/*.test.ts'] }], 'import/no-extraneous-dependencies': ['error', { devDependencies: ['**/*.test.ts'] }],
// TODO: The following rules from @strapi/eslint-config/typescript are disabled because they're causing problems we need to solve or fix
// to be solved in configuration
'node/no-unsupported-features/es-syntax': 'off',
'import/prefer-default-export': 'off',
'node/no-missing-import': 'off',
'@typescript-eslint/brace-style': 'off', // TODO: fix conflict with prettier/prettier in data-transfer/engine/index.ts
// to be cleaned up throughout codebase (too many to fix at the moment)
'@typescript-eslint/no-use-before-define': 'warn',
}, },
// Disable only for tests
overrides: [
{
files: ['**.test.ts'],
rules: {
'@typescript-eslint/ban-ts-comment': 'warn', // as long as javascript is allowed in our codebase, we want to test erroneous typescript usage
},
},
],
}; };

View File

@ -22,10 +22,17 @@ module.exports = {
// Backend typescript // Backend typescript
{ {
files: ['packages/**/*.ts', 'test/**/*.ts', 'scripts/**/*.ts', 'jest.*.ts'], files: ['packages/**/*.ts', 'test/**/*.ts', 'scripts/**/*.ts', 'jest.*.ts'],
excludedFiles: frontPaths, excludedFiles: [...frontPaths, '**/*.d.ts'],
...require('./.eslintrc.back.typescript.js'), ...require('./.eslintrc.back.typescript.js'),
}, },
// Type definitions
{
files: ['packages/**/*.d.ts', 'test/**/*.d.ts', 'scripts/**/*.d.ts'],
excludedFiles: frontPaths,
...require('./.eslintrc.back.type-definitions.js'),
},
// Frontend // Frontend
{ {
files: frontPaths, files: frontPaths,

View File

@ -29,7 +29,8 @@
], ],
"scripts": { "scripts": {
"prepare": "husky install", "prepare": "husky install",
"setup": "yarn && yarn build", "setup": "yarn clean && yarn && yarn build",
"clean": "lerna run --stream clean --no-private",
"watch": "lerna run --stream watch --no-private", "watch": "lerna run --stream watch --no-private",
"build": "lerna run --stream build --no-private", "build": "lerna run --stream build --no-private",
"generate": "plop --plopfile ./packages/generators/admin/plopfile.js", "generate": "plop --plopfile ./packages/generators/admin/plopfile.js",

View File

@ -130,7 +130,7 @@ export const extendExpectForDataTransferTests = () => {
} }
return { return {
pass: true, pass: true,
message: () => `Expected engine not to be valid`, message: () => 'Expected engine not to be valid',
}; };
}, },
toHaveSourceStagesCalledTimes(provider: ISourceProvider, times: number) { toHaveSourceStagesCalledTimes(provider: ISourceProvider, times: number) {
@ -145,6 +145,7 @@ export const extendExpectForDataTransferTests = () => {
return true; return true;
} }
} }
return false;
}); });
if (missing.length) { if (missing.length) {
@ -156,7 +157,7 @@ export const extendExpectForDataTransferTests = () => {
} }
return { return {
pass: true, pass: true,
message: () => `Expected source provider not to have all stages called`, message: () => 'Expected source provider not to have all stages called',
}; };
}, },
toHaveDestinationStagesCalledTimes(provider: IDestinationProvider, times: number) { toHaveDestinationStagesCalledTimes(provider: IDestinationProvider, times: number) {
@ -170,6 +171,8 @@ export const extendExpectForDataTransferTests = () => {
return true; return true;
} }
} }
return false;
}); });
if (missing.length) { if (missing.length) {
@ -183,7 +186,7 @@ export const extendExpectForDataTransferTests = () => {
} }
return { return {
pass: true, pass: true,
message: () => `Expected destination provider not to have all stages called`, message: () => 'Expected destination provider not to have all stages called',
}; };
}, },
toBeValidSourceProvider(provider: ISourceProvider) { toBeValidSourceProvider(provider: ISourceProvider) {
@ -199,7 +202,7 @@ export const extendExpectForDataTransferTests = () => {
} }
return { return {
pass: true, pass: true,
message: () => `Expected source provider not to be valid`, message: () => 'Expected source provider not to be valid',
}; };
}, },
toBeValidDestinationProvider(provider: IDestinationProvider) { toBeValidDestinationProvider(provider: IDestinationProvider) {
@ -215,7 +218,7 @@ export const extendExpectForDataTransferTests = () => {
} }
return { return {
pass: true, pass: true,
message: () => `Expected destination provider not to be valid`, message: () => 'Expected destination provider not to be valid',
}; };
}, },
}); });

View File

@ -4,25 +4,25 @@ import { EncryptionStrategy, Strategies, Algorithm } from '../../types';
// different key values depending on algorithm chosen // different key values depending on algorithm chosen
const getDecryptionStrategy = (algorithm: Algorithm): EncryptionStrategy => { const getDecryptionStrategy = (algorithm: Algorithm): EncryptionStrategy => {
const strategies: Strategies = { const strategies: Strategies = {
'aes-128-ecb': (key: string): Cipher => { 'aes-128-ecb'(key: string): Cipher {
const hashedKey = scryptSync(key, '', 16); const hashedKey = scryptSync(key, '', 16);
const initVector: BinaryLike | null = null; const initVector: BinaryLike | null = null;
const securityKey: CipherKey = hashedKey; const securityKey: CipherKey = hashedKey;
return createDecipheriv(algorithm, securityKey, initVector); return createDecipheriv(algorithm, securityKey, initVector);
}, },
aes128: (key: string): Cipher => { aes128(key: string): Cipher {
const hashedKey = scryptSync(key, '', 32); const hashedKey = scryptSync(key, '', 32);
const initVector: BinaryLike | null = hashedKey.slice(16); const initVector: BinaryLike | null = hashedKey.slice(16);
const securityKey: CipherKey = hashedKey.slice(0, 16); const securityKey: CipherKey = hashedKey.slice(0, 16);
return createDecipheriv(algorithm, securityKey, initVector); return createDecipheriv(algorithm, securityKey, initVector);
}, },
aes192: (key: string): Cipher => { aes192(key: string): Cipher {
const hashedKey = scryptSync(key, '', 40); const hashedKey = scryptSync(key, '', 40);
const initVector: BinaryLike | null = hashedKey.slice(24); const initVector: BinaryLike | null = hashedKey.slice(24);
const securityKey: CipherKey = hashedKey.slice(0, 24); const securityKey: CipherKey = hashedKey.slice(0, 24);
return createDecipheriv(algorithm, securityKey, initVector); return createDecipheriv(algorithm, securityKey, initVector);
}, },
aes256: (key: string): Cipher => { aes256(key: string): Cipher {
const hashedKey = scryptSync(key, '', 48); const hashedKey = scryptSync(key, '', 48);
const initVector: BinaryLike | null = hashedKey.slice(32); const initVector: BinaryLike | null = hashedKey.slice(32);
const securityKey: CipherKey = hashedKey.slice(0, 32); const securityKey: CipherKey = hashedKey.slice(0, 32);

View File

@ -4,25 +4,25 @@ import { EncryptionStrategy, Strategies, Algorithm } from '../../types';
// different key values depending on algorithm chosen // different key values depending on algorithm chosen
const getEncryptionStrategy = (algorithm: Algorithm): EncryptionStrategy => { const getEncryptionStrategy = (algorithm: Algorithm): EncryptionStrategy => {
const strategies: Strategies = { const strategies: Strategies = {
'aes-128-ecb': (key: string): Cipher => { 'aes-128-ecb'(key: string): Cipher {
const hashedKey = scryptSync(key, '', 16); const hashedKey = scryptSync(key, '', 16);
const initVector: BinaryLike | null = null; const initVector: BinaryLike | null = null;
const securityKey: CipherKey = hashedKey; const securityKey: CipherKey = hashedKey;
return createCipheriv(algorithm, securityKey, initVector); return createCipheriv(algorithm, securityKey, initVector);
}, },
aes128: (key: string): Cipher => { aes128(key: string): Cipher {
const hashedKey = scryptSync(key, '', 32); const hashedKey = scryptSync(key, '', 32);
const initVector: BinaryLike | null = hashedKey.slice(16); const initVector: BinaryLike | null = hashedKey.slice(16);
const securityKey: CipherKey = hashedKey.slice(0, 16); const securityKey: CipherKey = hashedKey.slice(0, 16);
return createCipheriv(algorithm, securityKey, initVector); return createCipheriv(algorithm, securityKey, initVector);
}, },
aes192: (key: string): Cipher => { aes192(key: string): Cipher {
const hashedKey = scryptSync(key, '', 40); const hashedKey = scryptSync(key, '', 40);
const initVector: BinaryLike | null = hashedKey.slice(24); const initVector: BinaryLike | null = hashedKey.slice(24);
const securityKey: CipherKey = hashedKey.slice(0, 24); const securityKey: CipherKey = hashedKey.slice(0, 24);
return createCipheriv(algorithm, securityKey, initVector); return createCipheriv(algorithm, securityKey, initVector);
}, },
aes256: (key: string): Cipher => { aes256(key: string): Cipher {
const hashedKey = scryptSync(key, '', 48); const hashedKey = scryptSync(key, '', 48);
const initVector: BinaryLike | null = hashedKey.slice(32); const initVector: BinaryLike | null = hashedKey.slice(32);
const securityKey: CipherKey = hashedKey.slice(0, 32); const securityKey: CipherKey = hashedKey.slice(0, 32);

View File

@ -9,7 +9,6 @@ import type {
IEntity, IEntity,
ILink, ILink,
ISourceProvider, ISourceProvider,
ITransferEngine,
ITransferEngineOptions, ITransferEngineOptions,
} from '../../../types'; } from '../../../types';
import { import {
@ -393,7 +392,7 @@ describe('Transfer engine', () => {
}); });
describe('progressStream', () => { describe('progressStream', () => {
test(`emits 'progress' events`, async () => { test("emits 'progress' events", async () => {
const source = createSource(); const source = createSource();
const engine = createTransferEngine(source, completeDestination, defaultOptions); const engine = createTransferEngine(source, completeDestination, defaultOptions);
@ -412,8 +411,8 @@ describe('Transfer engine', () => {
}); });
// TODO: to implement these, the mocked streams need to be improved // TODO: to implement these, the mocked streams need to be improved
test.todo(`emits 'start' events`); test.todo("emits 'start' events");
test.todo(`emits 'complete' events`); test.todo("emits 'complete' events");
}); });
describe('integrity checks', () => { describe('integrity checks', () => {

View File

@ -48,7 +48,7 @@ describe('Local File Destination Provider - Utils', () => {
const tarEntryStream = createTarEntryStream(archive, pathFactory, maxSize); const tarEntryStream = createTarEntryStream(archive, pathFactory, maxSize);
const write = async () => const write = async () =>
await new Promise((resolve, reject) => { new Promise((resolve, reject) => {
tarEntryStream.on('finish', resolve); tarEntryStream.on('finish', resolve);
tarEntryStream.on('error', reject); tarEntryStream.on('error', reject);
tarEntryStream.write(chunk); tarEntryStream.write(chunk);
@ -64,13 +64,13 @@ describe('Local File Destination Provider - Utils', () => {
const tarEntryStream = createTarEntryStream(archive, pathFactory, maxSize); const tarEntryStream = createTarEntryStream(archive, pathFactory, maxSize);
const write = async () => const write = async () =>
await new Promise((resolve, reject) => { new Promise((resolve, reject) => {
tarEntryStream.on('finish', resolve); tarEntryStream.on('finish', resolve);
tarEntryStream.on('error', reject); tarEntryStream.on('error', reject);
tarEntryStream.write(chunk); tarEntryStream.write(chunk);
}); });
expect(write).resolves; expect(write()).resolves.not.toBe(null);
}); });
}); });
}); });

View File

@ -1,12 +1,3 @@
import type {
IAsset,
IDestinationProvider,
IDestinationProviderTransferResults,
IMetadata,
ProviderType,
Stream,
} from '../../../types';
import fs from 'fs-extra'; import fs from 'fs-extra';
import tar from 'tar-stream'; import tar from 'tar-stream';
import path from 'path'; import path from 'path';
@ -16,20 +7,26 @@ import { stringer } from 'stream-json/jsonl/Stringer';
import { chain, Writable } from 'stream-chain'; import { chain, Writable } from 'stream-chain';
import { createEncryptionCipher } from '../../encryption/encrypt'; import { createEncryptionCipher } from '../../encryption/encrypt';
import type {
IAsset,
IDestinationProvider,
IDestinationProviderTransferResults,
IMetadata,
ProviderType,
Stream,
} from '../../../types';
import { createFilePathFactory, createTarEntryStream } from './utils'; import { createFilePathFactory, createTarEntryStream } from './utils';
export interface ILocalFileDestinationProviderOptions { export interface ILocalFileDestinationProviderOptions {
// Encryption
encryption: { encryption: {
enabled: boolean; enabled: boolean;
key?: string; key?: string;
}; };
// Compression
compression: { compression: {
enabled: boolean; enabled: boolean;
}; };
// File
file: { file: {
path: string; path: string;
maxSize?: number; maxSize?: number;
@ -51,12 +48,16 @@ export const createLocalFileDestinationProvider = (
}; };
class LocalFileDestinationProvider implements IDestinationProvider { class LocalFileDestinationProvider implements IDestinationProvider {
name: string = 'destination::local-file'; name = 'destination::local-file';
type: ProviderType = 'destination'; type: ProviderType = 'destination';
options: ILocalFileDestinationProviderOptions; options: ILocalFileDestinationProviderOptions;
results: ILocalFileDestinationProviderTransferResults = {}; results: ILocalFileDestinationProviderTransferResults = {};
#providersMetadata: { source?: IMetadata; destination?: IMetadata } = {}; #providersMetadata: { source?: IMetadata; destination?: IMetadata } = {};
#archive: { stream?: tar.Pack; pipeline?: Stream } = {}; #archive: { stream?: tar.Pack; pipeline?: Stream } = {};
constructor(options: ILocalFileDestinationProviderOptions) { constructor(options: ILocalFileDestinationProviderOptions) {
@ -66,17 +67,17 @@ class LocalFileDestinationProvider implements IDestinationProvider {
get #archivePath() { get #archivePath() {
const { encryption, compression, file } = this.options; const { encryption, compression, file } = this.options;
let path = `${file.path}.tar`; let filePath = `${file.path}.tar`;
if (compression.enabled) { if (compression.enabled) {
path += '.gz'; filePath += '.gz';
} }
if (encryption.enabled) { if (encryption.enabled) {
path += '.enc'; filePath += '.enc';
} }
return path; return filePath;
} }
setMetadata(target: ProviderType, metadata: IMetadata): IDestinationProvider { setMetadata(target: ProviderType, metadata: IMetadata): IDestinationProvider {

View File

@ -8,7 +8,7 @@ import tar from 'tar-stream';
*/ */
export const createFilePathFactory = export const createFilePathFactory =
(type: string) => (type: string) =>
(fileIndex: number = 0): string => { (fileIndex = 0): string => {
return path.join( return path.join(
// "{type}" directory // "{type}" directory
type, type,
@ -20,7 +20,7 @@ export const createFilePathFactory =
export const createTarEntryStream = ( export const createTarEntryStream = (
archive: tar.Pack, archive: tar.Pack,
pathFactory: (index?: number) => string, pathFactory: (index?: number) => string,
maxSize: number = 2.56e8 maxSize = 2.56e8
) => { ) => {
let fileIndex = 0; let fileIndex = 0;
let buffer = ''; let buffer = '';
@ -30,7 +30,8 @@ export const createTarEntryStream = (
return; return;
} }
const name = pathFactory(fileIndex++); fileIndex += 1;
const name = pathFactory(fileIndex);
const size = buffer.length; const size = buffer.length;
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {

View File

@ -1,18 +1,17 @@
import type { Readable } from 'stream'; import type { Readable } from 'stream';
import type { IMetadata, ISourceProvider, ProviderType } from '../../../types';
import fs from 'fs'; import fs from 'fs';
import zip from 'zlib'; import zip from 'zlib';
import tar from 'tar'; import tar from 'tar';
import path from 'path'; import path from 'path';
import { keyBy } from 'lodash/fp'; import { keyBy } from 'lodash/fp';
import { chain } from 'stream-chain'; import { chain } from 'stream-chain';
import { pipeline, PassThrough, Readable as ReadStream } from 'stream'; import { pipeline, PassThrough } from 'stream';
import { parser } from 'stream-json/jsonl/Parser'; import { parser } from 'stream-json/jsonl/Parser';
import type { IMetadata, ISourceProvider, ProviderType } from '../../../types';
import { createDecryptionCipher } from '../../encryption';
import { collect } from '../../utils'; import { collect } from '../../utils';
import { createDecryptionCipher } from '../../encryption';
type StreamItemArray = Parameters<typeof chain>[0]; type StreamItemArray = Parameters<typeof chain>[0];
@ -25,25 +24,18 @@ const METADATA_FILE_PATH = 'metadata.json';
* Provider options * Provider options
*/ */
export interface ILocalFileSourceProviderOptions { export interface ILocalFileSourceProviderOptions {
/** file: {
* Path to the backup archive path: string;
*/ };
backupFilePath: string;
/** encryption: {
* Whether the backup data is encrypted or not enabled: boolean;
*/ key?: string;
encrypted?: boolean; };
/** compression: {
* Encryption key used to decrypt the encrypted data (if necessary) enabled: boolean;
*/ };
encryptionKey?: string;
/**
* Whether the backup data is compressed or not
*/
compressed?: boolean;
} }
export const createLocalFileSourceProvider = (options: ILocalFileSourceProviderOptions) => { export const createLocalFileSourceProvider = (options: ILocalFileSourceProviderOptions) => {
@ -52,14 +44,17 @@ export const createLocalFileSourceProvider = (options: ILocalFileSourceProviderO
class LocalFileSourceProvider implements ISourceProvider { class LocalFileSourceProvider implements ISourceProvider {
type: ProviderType = 'source'; type: ProviderType = 'source';
name: string = 'source::local-file';
name = 'source::local-file';
options: ILocalFileSourceProviderOptions; options: ILocalFileSourceProviderOptions;
constructor(options: ILocalFileSourceProviderOptions) { constructor(options: ILocalFileSourceProviderOptions) {
this.options = options; this.options = options;
if (this.options.encrypted && this.options.encryptionKey === undefined) { const { encryption } = this.options;
if (encryption.enabled && encryption.key === undefined) {
throw new Error('Missing encryption key'); throw new Error('Missing encryption key');
} }
} }
@ -68,13 +63,13 @@ class LocalFileSourceProvider implements ISourceProvider {
* Pre flight checks regarding the provided options (making sure that the provided path is correct, etc...) * Pre flight checks regarding the provided options (making sure that the provided path is correct, etc...)
*/ */
bootstrap() { bootstrap() {
const path = this.options.backupFilePath; const { path: filePath } = this.options.file;
const isValidBackupPath = fs.existsSync(path); const isValidBackupPath = fs.existsSync(filePath);
// Check if the provided path exists // Check if the provided path exists
if (!isValidBackupPath) { if (!isValidBackupPath) {
throw new Error( throw new Error(
`Invalid backup file path provided. "${path}" does not exist on the filesystem.` `Invalid backup file path provided. "${filePath}" does not exist on the filesystem.`
); );
} }
} }
@ -117,13 +112,13 @@ class LocalFileSourceProvider implements ISourceProvider {
[ [
inStream, inStream,
new tar.Parse({ new tar.Parse({
filter(path, entry) { filter(filePath, entry) {
if (entry.type !== 'File') { if (entry.type !== 'File') {
return false; return false;
} }
const parts = path.split('/'); const parts = filePath.split('/');
return parts[0] === 'assets' && parts[1] == 'uploads'; return parts[0] === 'assets' && parts[1] === 'uploads';
}, },
onentry(entry) { onentry(entry) {
const { path: filePath, size } = entry; const { path: filePath, size } = entry;
@ -138,13 +133,17 @@ class LocalFileSourceProvider implements ISourceProvider {
return outStream; return outStream;
} }
#getBackupStream(decompress: boolean = true) { #getBackupStream() {
const path = this.options.backupFilePath; const { file, encryption, compression } = this.options;
const readStream = fs.createReadStream(path);
const streams: StreamItemArray = [readStream];
// Handle decompression const fileStream = fs.createReadStream(file.path);
if (decompress) { const streams: StreamItemArray = [fileStream];
if (encryption.enabled && encryption.key) {
streams.push(createDecryptionCipher(encryption.key));
}
if (compression.enabled) {
streams.push(zip.createGunzip()); streams.push(zip.createGunzip());
} }
@ -152,7 +151,6 @@ class LocalFileSourceProvider implements ISourceProvider {
} }
#streamJsonlDirectory(directory: string) { #streamJsonlDirectory(directory: string) {
const options = this.options;
const inStream = this.#getBackupStream(); const inStream = this.#getBackupStream();
const outStream = new PassThrough({ objectMode: true }); const outStream = new PassThrough({ objectMode: true });
@ -161,12 +159,12 @@ class LocalFileSourceProvider implements ISourceProvider {
[ [
inStream, inStream,
new tar.Parse({ new tar.Parse({
filter(path, entry) { filter(filePath, entry) {
if (entry.type !== 'File') { if (entry.type !== 'File') {
return false; return false;
} }
const parts = path.split('/'); const parts = filePath.split('/');
if (parts.length !== 2) { if (parts.length !== 2) {
return false; return false;
@ -176,22 +174,12 @@ class LocalFileSourceProvider implements ISourceProvider {
}, },
onentry(entry) { onentry(entry) {
const transforms = []; const transforms = [
if (options.encrypted) {
transforms.push(createDecryptionCipher(options.encryptionKey!));
}
if (options.compressed) {
transforms.push(zip.createGunzip());
}
transforms.push(
// JSONL parser to read the data chunks one by one (line by line) // JSONL parser to read the data chunks one by one (line by line)
parser(), parser(),
// The JSONL parser returns each line as key/value // The JSONL parser returns each line as key/value
(line: { key: string; value: any }) => line.value (line: { key: string; value: any }) => line.value,
); ];
entry entry
// Pipe transforms // Pipe transforms
@ -213,7 +201,7 @@ class LocalFileSourceProvider implements ISourceProvider {
return outStream; return outStream;
} }
async #parseJSONFile<T extends {} = any>( async #parseJSONFile<T extends Record<string, any> = any>(
fileStream: NodeJS.ReadableStream, fileStream: NodeJS.ReadableStream,
filePath: string filePath: string
): Promise<T> { ): Promise<T> {
@ -226,8 +214,8 @@ class LocalFileSourceProvider implements ISourceProvider {
/** /**
* Filter the parsed entries to only keep the one that matches the given filepath * Filter the parsed entries to only keep the one that matches the given filepath
*/ */
filter(path, entry) { filter(entryPath, entry) {
return path === filePath && entry.type === 'File'; return entryPath === filePath && entry.type === 'File';
}, },
/** /**
@ -250,7 +238,7 @@ class LocalFileSourceProvider implements ISourceProvider {
() => { () => {
// If the promise hasn't been resolved and we've parsed all // If the promise hasn't been resolved and we've parsed all
// the archive entries, then the file doesn't exist // the archive entries, then the file doesn't exist
reject(`${filePath} not found in the archive stream`); reject(new Error(`File "${filePath}" not found`));
} }
); );
}); });

View File

@ -1,4 +1,4 @@
import { deleteAllRecords } from '../restore'; import { deleteAllRecords, restoreConfigs } from '../restore';
import { getStrapiFactory, getContentTypes } from '../../test-utils'; import { getStrapiFactory, getContentTypes } from '../../test-utils';
const entities = [ const entities = [
@ -32,6 +32,10 @@ const entities = [
}, },
]; ];
afterEach(() => {
jest.clearAllMocks();
});
const deleteMany = jest.fn(async (uid: string) => ({ const deleteMany = jest.fn(async (uid: string) => ({
count: entities.filter((entity) => entity.contentType.uid === uid).length, count: entities.filter((entity) => entity.contentType.uid === uid).length,
})); }));
@ -41,6 +45,7 @@ const query = jest.fn(() => {
deleteMany: jest.fn(() => ({ deleteMany: jest.fn(() => ({
count: 0, count: 0,
})), })),
create: jest.fn((data) => data),
}; };
}); });
@ -72,8 +77,66 @@ describe('Restore ', () => {
const { count } = await deleteAllRecords(strapi, { const { count } = await deleteAllRecords(strapi, {
/* @ts-ignore: disable-next-line */ /* @ts-ignore: disable-next-line */
contentTypes: [getContentTypes()['foo']], contentTypes: [getContentTypes().foo],
}); });
expect(count).toBe(3); expect(count).toBe(3);
}); });
test('Should add core store data', async () => {
const strapi = getStrapiFactory({
contentTypes: getContentTypes(),
db: {
query,
},
})();
const config = {
type: 'core-store',
value: {
key: 'test-key',
type: 'test-type',
environment: null,
tag: 'tag',
value: {},
},
};
const result = await restoreConfigs(strapi, config);
expect(strapi.db.query).toBeCalledTimes(1);
expect(strapi.db.query).toBeCalledWith('strapi::core-store');
expect(result.data).toMatchObject(config.value);
});
test('Should add webhook data', async () => {
const strapi = getStrapiFactory({
contentTypes: getContentTypes(),
db: {
query,
},
})();
const config = {
type: 'webhook',
value: {
id: 4,
name: 'christian',
url: 'https://facebook.com',
headers: { null: '' },
events: [
'entry.create',
'entry.update',
'entry.delete',
'entry.publish',
'entry.unpublish',
'media.create',
'media.update',
'media.delete',
],
enabled: true,
},
};
const result = await restoreConfigs(strapi, config);
expect(strapi.db.query).toBeCalledTimes(1);
expect(strapi.db.query).toBeCalledWith('webhook');
expect(result.data).toMatchObject(config.value);
});
}); });

View File

@ -1,11 +1,12 @@
// import { createLogger } from '@strapi/logger'; // import { createLogger } from '@strapi/logger';
import type { IDestinationProvider, IMetadata, ProviderType } from '../../../types';
import { deleteAllRecords, DeleteOptions } from './restore';
import chalk from 'chalk';
import { Writable } from 'stream'; import { Writable } from 'stream';
import path from 'path'; import path from 'path';
import * as fse from 'fs-extra'; import * as fse from 'fs-extra';
import type { IConfiguration, IDestinationProvider, IMetadata, ProviderType } from '../../../types';
import { deleteAllRecords, DeleteOptions, restoreConfigs } from './restore';
import { mapSchemasValues } from '../../utils'; import { mapSchemasValues } from '../../utils';
export const VALID_STRATEGIES = ['restore', 'merge']; export const VALID_STRATEGIES = ['restore', 'merge'];
@ -16,10 +17,6 @@ interface ILocalStrapiDestinationProviderOptions {
strategy: 'restore' | 'merge'; strategy: 'restore' | 'merge';
} }
// TODO: getting some type errors with @strapi/logger that need to be resolved first
// const log = createLogger();
const log = console;
export const createLocalStrapiDestinationProvider = ( export const createLocalStrapiDestinationProvider = (
options: ILocalStrapiDestinationProviderOptions options: ILocalStrapiDestinationProviderOptions
) => { ) => {
@ -27,10 +24,12 @@ export const createLocalStrapiDestinationProvider = (
}; };
class LocalStrapiDestinationProvider implements IDestinationProvider { class LocalStrapiDestinationProvider implements IDestinationProvider {
name: string = 'destination::local-strapi'; name = 'destination::local-strapi';
type: ProviderType = 'destination'; type: ProviderType = 'destination';
options: ILocalStrapiDestinationProviderOptions; options: ILocalStrapiDestinationProviderOptions;
strapi?: Strapi.Strapi; strapi?: Strapi.Strapi;
constructor(options: ILocalStrapiDestinationProviderOptions) { constructor(options: ILocalStrapiDestinationProviderOptions) {
@ -48,7 +47,7 @@ class LocalStrapiDestinationProvider implements IDestinationProvider {
#validateOptions() { #validateOptions() {
if (!VALID_STRATEGIES.includes(this.options.strategy)) { if (!VALID_STRATEGIES.includes(this.options.strategy)) {
throw new Error('Invalid stategy ' + this.options.strategy); throw new Error(`Invalid stategy ${this.options.strategy}`);
} }
} }
@ -56,7 +55,7 @@ class LocalStrapiDestinationProvider implements IDestinationProvider {
if (!this.strapi) { if (!this.strapi) {
throw new Error('Strapi instance not found'); throw new Error('Strapi instance not found');
} }
return await deleteAllRecords(this.strapi, this.options.restore); return deleteAllRecords(this.strapi, this.options.restore);
} }
async beforeTransfer() { async beforeTransfer() {
@ -117,4 +116,30 @@ class LocalStrapiDestinationProvider implements IDestinationProvider {
}, },
}); });
} }
async getConfigurationStream(): Promise<Writable> {
if (!this.strapi) {
throw new Error('Not able to stream Configurations. Strapi instance not found');
}
return new Writable({
objectMode: true,
write: async (config: IConfiguration<any>, _encoding, callback) => {
try {
if (this.options.strategy === 'restore' && this.strapi) {
await restoreConfigs(this.strapi, config);
}
callback();
} catch (error) {
callback(
new Error(
`Failed to import ${chalk.yellowBright(config.type)} (${chalk.greenBright(
config.value.id
)}`
)
);
}
},
});
}
} }

View File

@ -1,4 +1,5 @@
import type { ContentTypeSchema } from '@strapi/strapi'; import type { ContentTypeSchema } from '@strapi/strapi';
import type { IConfiguration } from '../../../types';
export type DeleteOptions = { export type DeleteOptions = {
contentTypes?: ContentTypeSchema[]; contentTypes?: ContentTypeSchema[];
@ -33,3 +34,28 @@ export const deleteAllRecords = async (strapi: Strapi.Strapi, deleteOptions?: De
return { count }; return { count };
}; };
const restoreCoreStore = async (strapi: Strapi.Strapi, data: any) => {
return strapi.db.query('strapi::core-store').create({
data: {
...data,
value: JSON.stringify(data.value),
},
});
};
const restoreWebhooks = async (strapi: Strapi.Strapi, data: any) => {
return strapi.db.query('webhook').create({
data,
});
};
export const restoreConfigs = async (strapi: Strapi.Strapi, config: IConfiguration) => {
if (config.type === 'core-store') {
return restoreCoreStore(strapi, config.value);
}
if (config.type === 'webhook') {
return restoreWebhooks(strapi, config.value);
}
};

View File

@ -1,6 +1,5 @@
import type { IEntity } from '../../../../types';
import { Readable, PassThrough } from 'stream'; import { Readable, PassThrough } from 'stream';
import type { IEntity } from '../../../../types';
import { import {
collect, collect,

View File

@ -1,9 +1,8 @@
import { Readable } from 'stream';
import type { IEntity } from '../../../../types'; import type { IEntity } from '../../../../types';
import { Readable } from 'stream';
import { collect, createMockedQueryBuilder, getStrapiFactory } from '../../../__tests__/test-utils'; import { collect, createMockedQueryBuilder, getStrapiFactory } from '../../../__tests__/test-utils';
import { createLocalStrapiSourceProvider } from '../'; import { createLocalStrapiSourceProvider } from '..';
describe('Local Strapi Source Provider', () => { describe('Local Strapi Source Provider', () => {
describe('Bootstrap', () => { describe('Bootstrap', () => {

View File

@ -347,7 +347,7 @@ describe('Local Strapi Source Provider - Entities Streaming', () => {
expect(populate).toEqual({}); expect(populate).toEqual({});
}); });
test(`Should return an empty object if there are components or dynamic zones but they doesn't contain relations`, () => { test("Should return an empty object if there are components or dynamic zones but they doesn't contain relations", () => {
const strapi = getStrapiFactory({ const strapi = getStrapiFactory({
contentTypes: { contentTypes: {
blog: { blog: {

View File

@ -1,5 +1,4 @@
import { chain } from 'stream-chain'; import { chain } from 'stream-chain';
import { mapValues, pick } from 'lodash/fp';
import { Readable } from 'stream'; import { Readable } from 'stream';
import type { IMetadata, ISourceProvider, ProviderType } from '../../../types'; import type { IMetadata, ISourceProvider, ProviderType } from '../../../types';
@ -20,10 +19,12 @@ export const createLocalStrapiSourceProvider = (options: ILocalStrapiSourceProvi
}; };
class LocalStrapiSourceProvider implements ISourceProvider { class LocalStrapiSourceProvider implements ISourceProvider {
name: string = 'source::local-strapi'; name = 'source::local-strapi';
type: ProviderType = 'source'; type: ProviderType = 'source';
options: ILocalStrapiSourceProviderOptions; options: ILocalStrapiSourceProviderOptions;
strapi?: Strapi.Strapi; strapi?: Strapi.Strapi;
constructor(options: ILocalStrapiSourceProviderOptions) { constructor(options: ILocalStrapiSourceProviderOptions) {

View File

@ -1,4 +1,4 @@
import type { ContentTypeSchema, GetAttributesValues, RelationsType } from '@strapi/strapi'; import type { ContentTypeSchema } from '@strapi/strapi';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { castArray } from 'lodash/fp'; import { castArray } from 'lodash/fp';

View File

@ -1,7 +1,6 @@
import type { RelationsType } from '@strapi/strapi'; import type { RelationsType } from '@strapi/strapi';
import type { ILink } from '../../../../types';
import { concat, set, isEmpty } from 'lodash/fp'; import { concat, set, isEmpty } from 'lodash/fp';
import type { ILink } from '../../../../types';
// TODO: Fix any typings when we'll have better Strapi types // TODO: Fix any typings when we'll have better Strapi types
@ -49,7 +48,7 @@ export const parseEntityLinks = (entity: any, populate: any, schema: any, strapi
.map(({ __component, ...item }: any) => .map(({ __component, ...item }: any) =>
parseEntityLinks(item, subPopulate.populate, strapi.components[__component], strapi) parseEntityLinks(item, subPopulate.populate, strapi.components[__component], strapi)
) )
.reduce((acc: any, links: any) => acc.concat(...links), []); .reduce((acc: any, rlinks: any) => acc.concat(...rlinks), []);
links.push(...dzLinks); links.push(...dzLinks);
} }
@ -90,6 +89,7 @@ export const parseRelationLinks = ({ entity, schema, fieldName, value }: any): I
const isMorphRelation = relation.startsWith('morph'); const isMorphRelation = relation.startsWith('morph');
const isCircularRelation = !isMorphRelation && target === schema.uid; const isCircularRelation = !isMorphRelation && target === schema.uid;
// eslint-disable-next-line no-nested-ternary
const kind: ILink['kind'] = isMorphRelation const kind: ILink['kind'] = isMorphRelation
? // Polymorphic relations ? // Polymorphic relations
'relation.morph' 'relation.morph'

View File

@ -4,12 +4,12 @@ import { jsonDiffs } from '../utils';
const strategies = { const strategies = {
// No diffs // No diffs
exact: (diffs: Diff[]) => { exact(diffs: Diff[]) {
return diffs; return diffs;
}, },
// Diffs allowed on specific attributes properties // Diffs allowed on specific attributes properties
strict: (diffs: Diff[]) => { strict(diffs: Diff[]) {
const isIgnorableDiff = ({ path }: Diff) => { const isIgnorableDiff = ({ path }: Diff) => {
return ( return (
path.length === 3 && path.length === 3 &&

View File

@ -1,7 +1,7 @@
import type { Readable } from 'stream'; import type { Readable } from 'stream';
import type { Context, Diff } from '../types';
import { isArray, isObject, zip, isEqual, uniq, mapValues, pick } from 'lodash/fp'; import { isArray, isObject, zip, isEqual, uniq, mapValues, pick } from 'lodash/fp';
import type { Context, Diff } from '../types';
/** /**
* Collect every entity in a Readable stream * Collect every entity in a Readable stream
@ -24,6 +24,9 @@ export const jsonDiffs = (a: unknown, b: unknown, ctx: Context = createContext()
const diffs: Diff[] = []; const diffs: Diff[] = [];
const { path } = ctx; const { path } = ctx;
const aType = typeof a;
const bType = typeof b;
// Define helpers // Define helpers
const added = () => { const added = () => {
@ -46,9 +49,6 @@ export const jsonDiffs = (a: unknown, b: unknown, ctx: Context = createContext()
return diffs; return diffs;
}; };
const aType = typeof a;
const bType = typeof b;
if (aType === 'undefined') { if (aType === 'undefined') {
return added(); return added();
} }
@ -66,7 +66,7 @@ export const jsonDiffs = (a: unknown, b: unknown, ctx: Context = createContext()
diffs.push(...kDiffs); diffs.push(...kDiffs);
k++; k += 1;
} }
return diffs; return diffs;

View File

@ -122,6 +122,13 @@ interface ICircularLink extends IDefaultLink {
kind: 'relation.circular'; kind: 'relation.circular';
} }
/**
* Strapi configurations
*/
interface IConfiguration<T = unknown> {
type: 'core-store' | 'webhook';
value: T;
}
export interface IAsset { export interface IAsset {
filename: string; filename: string;
filepath: string; filepath: string;

View File

@ -5,6 +5,7 @@
// FIXME // FIXME
/* eslint-disable import/extensions */ /* eslint-disable import/extensions */
const _ = require('lodash'); const _ = require('lodash');
const path = require('path');
const resolveCwd = require('resolve-cwd'); const resolveCwd = require('resolve-cwd');
const { yellow } = require('chalk'); const { yellow } = require('chalk');
const { Command, Option } = require('commander'); const { Command, Option } = require('commander');
@ -316,14 +317,15 @@ program
'path and filename to the Strapi export file you want to import' 'path and filename to the Strapi export file you want to import'
) )
.addOption( .addOption(
new Option('--key <string>', 'Provide encryption key in command instead of using a prompt') new Option('-k, --key <string>', 'Provide encryption key in command instead of using a prompt')
) )
.allowExcessArguments(false) .allowExcessArguments(false)
.hook('preAction', async (thisCommand) => { .hook('preAction', async (thisCommand) => {
const opts = thisCommand.opts(); const opts = thisCommand.opts();
const ext = path.extname(String(opts.file));
// check extension to guess if we should prompt for key // check extension to guess if we should prompt for key
if (String(opts.file).endsWith('.enc')) { if (ext === '.enc') {
if (!opts.key) { if (!opts.key) {
const answers = await inquirer.prompt([ const answers = await inquirer.prompt([
{ {

View File

@ -8,10 +8,15 @@ const {
// eslint-disable-next-line import/no-unresolved, node/no-missing-require // eslint-disable-next-line import/no-unresolved, node/no-missing-require
} = require('@strapi/data-transfer'); } = require('@strapi/data-transfer');
const { isObject } = require('lodash/fp'); const { isObject } = require('lodash/fp');
const path = require('path');
const strapi = require('../../index'); const strapi = require('../../index');
const { buildTransferTable } = require('./utils'); const { buildTransferTable } = require('./utils');
/**
* @typedef {import('@strapi/data-transfer').ILocalFileSourceProviderOptions} ILocalFileSourceProviderOptions
*/
const logger = console; const logger = console;
module.exports = async (opts) => { module.exports = async (opts) => {
@ -20,14 +25,12 @@ module.exports = async (opts) => {
logger.error('Could not parse arguments'); logger.error('Could not parse arguments');
process.exit(1); process.exit(1);
} }
const filename = opts.file;
/** /**
* From strapi backup file * From strapi backup file
*/ */
const sourceOptions = { const sourceOptions = getLocalFileSourceOptions(opts);
backupFilePath: filename,
};
const source = createLocalFileSourceProvider(sourceOptions); const source = createLocalFileSourceProvider(sourceOptions);
/** /**
@ -69,16 +72,50 @@ module.exports = async (opts) => {
const engine = createTransferEngine(source, destination, engineOptions); const engine = createTransferEngine(source, destination, engineOptions);
try { try {
logger.log('Starting import...'); logger.info('Starting import...');
const results = await engine.transfer(); const results = await engine.transfer();
const table = buildTransferTable(results.engine); const table = buildTransferTable(results.engine);
logger.log(table.toString()); logger.info(table.toString());
logger.log('Import process has been completed successfully!'); logger.info('Import process has been completed successfully!');
process.exit(0); process.exit(0);
} catch (e) { } catch (e) {
logger.log(`Import process failed unexpectedly: ${e.message}`); logger.error(`Import process failed unexpectedly: ${e.message}`);
process.exit(1); process.exit(1);
} }
}; };
/**
* Infer local file source provider options based on a given filename
*
* @param {{ file: string; key?: string }} opts
*
* @return {ILocalFileSourceProviderOptions}
*/
const getLocalFileSourceOptions = (opts) => {
/**
* @type {ILocalFileSourceProviderOptions}
*/
const options = {
file: { path: opts.file },
compression: { enabled: false },
encryption: { enabled: false },
};
const { extname, parse } = path;
let file = options.file.path;
if (extname(file) === '.enc') {
file = parse(file).name;
options.encryption = { enabled: true, key: opts.key };
}
if (extname(file) === '.gz') {
file = parse(file).name;
options.compression = { enabled: true };
}
return options;
};

8
tsconfig.eslint.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"incremental": true
}
}