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 = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.eslint.json',
},
plugins: ['@typescript-eslint'],
/**
* 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'],
extends: ['@strapi/eslint-config/typescript'],
globals: {
strapi: false,
},
@ -14,5 +14,22 @@ module.exports = {
rules: {
...require('./.eslintrc.back.js').rules,
'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
{
files: ['packages/**/*.ts', 'test/**/*.ts', 'scripts/**/*.ts', 'jest.*.ts'],
excludedFiles: frontPaths,
excludedFiles: [...frontPaths, '**/*.d.ts'],
...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
{
files: frontPaths,

View File

@ -29,7 +29,8 @@
],
"scripts": {
"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",
"build": "lerna run --stream build --no-private",
"generate": "plop --plopfile ./packages/generators/admin/plopfile.js",

View File

@ -130,7 +130,7 @@ export const extendExpectForDataTransferTests = () => {
}
return {
pass: true,
message: () => `Expected engine not to be valid`,
message: () => 'Expected engine not to be valid',
};
},
toHaveSourceStagesCalledTimes(provider: ISourceProvider, times: number) {
@ -145,6 +145,7 @@ export const extendExpectForDataTransferTests = () => {
return true;
}
}
return false;
});
if (missing.length) {
@ -156,7 +157,7 @@ export const extendExpectForDataTransferTests = () => {
}
return {
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) {
@ -170,6 +171,8 @@ export const extendExpectForDataTransferTests = () => {
return true;
}
}
return false;
});
if (missing.length) {
@ -183,7 +186,7 @@ export const extendExpectForDataTransferTests = () => {
}
return {
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) {
@ -199,7 +202,7 @@ export const extendExpectForDataTransferTests = () => {
}
return {
pass: true,
message: () => `Expected source provider not to be valid`,
message: () => 'Expected source provider not to be valid',
};
},
toBeValidDestinationProvider(provider: IDestinationProvider) {
@ -215,7 +218,7 @@ export const extendExpectForDataTransferTests = () => {
}
return {
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
const getDecryptionStrategy = (algorithm: Algorithm): EncryptionStrategy => {
const strategies: Strategies = {
'aes-128-ecb': (key: string): Cipher => {
'aes-128-ecb'(key: string): Cipher {
const hashedKey = scryptSync(key, '', 16);
const initVector: BinaryLike | null = null;
const securityKey: CipherKey = hashedKey;
return createDecipheriv(algorithm, securityKey, initVector);
},
aes128: (key: string): Cipher => {
aes128(key: string): Cipher {
const hashedKey = scryptSync(key, '', 32);
const initVector: BinaryLike | null = hashedKey.slice(16);
const securityKey: CipherKey = hashedKey.slice(0, 16);
return createDecipheriv(algorithm, securityKey, initVector);
},
aes192: (key: string): Cipher => {
aes192(key: string): Cipher {
const hashedKey = scryptSync(key, '', 40);
const initVector: BinaryLike | null = hashedKey.slice(24);
const securityKey: CipherKey = hashedKey.slice(0, 24);
return createDecipheriv(algorithm, securityKey, initVector);
},
aes256: (key: string): Cipher => {
aes256(key: string): Cipher {
const hashedKey = scryptSync(key, '', 48);
const initVector: BinaryLike | null = hashedKey.slice(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
const getEncryptionStrategy = (algorithm: Algorithm): EncryptionStrategy => {
const strategies: Strategies = {
'aes-128-ecb': (key: string): Cipher => {
'aes-128-ecb'(key: string): Cipher {
const hashedKey = scryptSync(key, '', 16);
const initVector: BinaryLike | null = null;
const securityKey: CipherKey = hashedKey;
return createCipheriv(algorithm, securityKey, initVector);
},
aes128: (key: string): Cipher => {
aes128(key: string): Cipher {
const hashedKey = scryptSync(key, '', 32);
const initVector: BinaryLike | null = hashedKey.slice(16);
const securityKey: CipherKey = hashedKey.slice(0, 16);
return createCipheriv(algorithm, securityKey, initVector);
},
aes192: (key: string): Cipher => {
aes192(key: string): Cipher {
const hashedKey = scryptSync(key, '', 40);
const initVector: BinaryLike | null = hashedKey.slice(24);
const securityKey: CipherKey = hashedKey.slice(0, 24);
return createCipheriv(algorithm, securityKey, initVector);
},
aes256: (key: string): Cipher => {
aes256(key: string): Cipher {
const hashedKey = scryptSync(key, '', 48);
const initVector: BinaryLike | null = hashedKey.slice(32);
const securityKey: CipherKey = hashedKey.slice(0, 32);

View File

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

View File

@ -48,7 +48,7 @@ describe('Local File Destination Provider - Utils', () => {
const tarEntryStream = createTarEntryStream(archive, pathFactory, maxSize);
const write = async () =>
await new Promise((resolve, reject) => {
new Promise((resolve, reject) => {
tarEntryStream.on('finish', resolve);
tarEntryStream.on('error', reject);
tarEntryStream.write(chunk);
@ -64,13 +64,13 @@ describe('Local File Destination Provider - Utils', () => {
const tarEntryStream = createTarEntryStream(archive, pathFactory, maxSize);
const write = async () =>
await new Promise((resolve, reject) => {
new Promise((resolve, reject) => {
tarEntryStream.on('finish', resolve);
tarEntryStream.on('error', reject);
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 tar from 'tar-stream';
import path from 'path';
@ -16,20 +7,26 @@ import { stringer } from 'stream-json/jsonl/Stringer';
import { chain, Writable } from 'stream-chain';
import { createEncryptionCipher } from '../../encryption/encrypt';
import type {
IAsset,
IDestinationProvider,
IDestinationProviderTransferResults,
IMetadata,
ProviderType,
Stream,
} from '../../../types';
import { createFilePathFactory, createTarEntryStream } from './utils';
export interface ILocalFileDestinationProviderOptions {
// Encryption
encryption: {
enabled: boolean;
key?: string;
};
// Compression
compression: {
enabled: boolean;
};
// File
file: {
path: string;
maxSize?: number;
@ -51,12 +48,16 @@ export const createLocalFileDestinationProvider = (
};
class LocalFileDestinationProvider implements IDestinationProvider {
name: string = 'destination::local-file';
name = 'destination::local-file';
type: ProviderType = 'destination';
options: ILocalFileDestinationProviderOptions;
results: ILocalFileDestinationProviderTransferResults = {};
#providersMetadata: { source?: IMetadata; destination?: IMetadata } = {};
#archive: { stream?: tar.Pack; pipeline?: Stream } = {};
constructor(options: ILocalFileDestinationProviderOptions) {
@ -66,17 +67,17 @@ class LocalFileDestinationProvider implements IDestinationProvider {
get #archivePath() {
const { encryption, compression, file } = this.options;
let path = `${file.path}.tar`;
let filePath = `${file.path}.tar`;
if (compression.enabled) {
path += '.gz';
filePath += '.gz';
}
if (encryption.enabled) {
path += '.enc';
filePath += '.enc';
}
return path;
return filePath;
}
setMetadata(target: ProviderType, metadata: IMetadata): IDestinationProvider {

View File

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

View File

@ -1,18 +1,17 @@
import type { Readable } from 'stream';
import type { IMetadata, ISourceProvider, ProviderType } from '../../../types';
import fs from 'fs';
import zip from 'zlib';
import tar from 'tar';
import path from 'path';
import { keyBy } from 'lodash/fp';
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 type { IMetadata, ISourceProvider, ProviderType } from '../../../types';
import { createDecryptionCipher } from '../../encryption';
import { collect } from '../../utils';
import { createDecryptionCipher } from '../../encryption';
type StreamItemArray = Parameters<typeof chain>[0];
@ -25,25 +24,18 @@ const METADATA_FILE_PATH = 'metadata.json';
* Provider options
*/
export interface ILocalFileSourceProviderOptions {
/**
* Path to the backup archive
*/
backupFilePath: string;
file: {
path: string;
};
/**
* Whether the backup data is encrypted or not
*/
encrypted?: boolean;
encryption: {
enabled: boolean;
key?: string;
};
/**
* Encryption key used to decrypt the encrypted data (if necessary)
*/
encryptionKey?: string;
/**
* Whether the backup data is compressed or not
*/
compressed?: boolean;
compression: {
enabled: boolean;
};
}
export const createLocalFileSourceProvider = (options: ILocalFileSourceProviderOptions) => {
@ -52,14 +44,17 @@ export const createLocalFileSourceProvider = (options: ILocalFileSourceProviderO
class LocalFileSourceProvider implements ISourceProvider {
type: ProviderType = 'source';
name: string = 'source::local-file';
name = 'source::local-file';
options: ILocalFileSourceProviderOptions;
constructor(options: ILocalFileSourceProviderOptions) {
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');
}
}
@ -68,13 +63,13 @@ class LocalFileSourceProvider implements ISourceProvider {
* Pre flight checks regarding the provided options (making sure that the provided path is correct, etc...)
*/
bootstrap() {
const path = this.options.backupFilePath;
const isValidBackupPath = fs.existsSync(path);
const { path: filePath } = this.options.file;
const isValidBackupPath = fs.existsSync(filePath);
// Check if the provided path exists
if (!isValidBackupPath) {
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,
new tar.Parse({
filter(path, entry) {
filter(filePath, entry) {
if (entry.type !== 'File') {
return false;
}
const parts = path.split('/');
return parts[0] === 'assets' && parts[1] == 'uploads';
const parts = filePath.split('/');
return parts[0] === 'assets' && parts[1] === 'uploads';
},
onentry(entry) {
const { path: filePath, size } = entry;
@ -138,13 +133,17 @@ class LocalFileSourceProvider implements ISourceProvider {
return outStream;
}
#getBackupStream(decompress: boolean = true) {
const path = this.options.backupFilePath;
const readStream = fs.createReadStream(path);
const streams: StreamItemArray = [readStream];
#getBackupStream() {
const { file, encryption, compression } = this.options;
// Handle decompression
if (decompress) {
const fileStream = fs.createReadStream(file.path);
const streams: StreamItemArray = [fileStream];
if (encryption.enabled && encryption.key) {
streams.push(createDecryptionCipher(encryption.key));
}
if (compression.enabled) {
streams.push(zip.createGunzip());
}
@ -152,7 +151,6 @@ class LocalFileSourceProvider implements ISourceProvider {
}
#streamJsonlDirectory(directory: string) {
const options = this.options;
const inStream = this.#getBackupStream();
const outStream = new PassThrough({ objectMode: true });
@ -161,12 +159,12 @@ class LocalFileSourceProvider implements ISourceProvider {
[
inStream,
new tar.Parse({
filter(path, entry) {
filter(filePath, entry) {
if (entry.type !== 'File') {
return false;
}
const parts = path.split('/');
const parts = filePath.split('/');
if (parts.length !== 2) {
return false;
@ -176,22 +174,12 @@ class LocalFileSourceProvider implements ISourceProvider {
},
onentry(entry) {
const transforms = [];
if (options.encrypted) {
transforms.push(createDecryptionCipher(options.encryptionKey!));
}
if (options.compressed) {
transforms.push(zip.createGunzip());
}
transforms.push(
const transforms = [
// JSONL parser to read the data chunks one by one (line by line)
parser(),
// The JSONL parser returns each line as key/value
(line: { key: string; value: any }) => line.value
);
(line: { key: string; value: any }) => line.value,
];
entry
// Pipe transforms
@ -213,7 +201,7 @@ class LocalFileSourceProvider implements ISourceProvider {
return outStream;
}
async #parseJSONFile<T extends {} = any>(
async #parseJSONFile<T extends Record<string, any> = any>(
fileStream: NodeJS.ReadableStream,
filePath: string
): 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(path, entry) {
return path === filePath && entry.type === 'File';
filter(entryPath, entry) {
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
// 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';
const entities = [
@ -32,6 +32,10 @@ const entities = [
},
];
afterEach(() => {
jest.clearAllMocks();
});
const deleteMany = jest.fn(async (uid: string) => ({
count: entities.filter((entity) => entity.contentType.uid === uid).length,
}));
@ -41,6 +45,7 @@ const query = jest.fn(() => {
deleteMany: jest.fn(() => ({
count: 0,
})),
create: jest.fn((data) => data),
};
});
@ -72,8 +77,66 @@ describe('Restore ', () => {
const { count } = await deleteAllRecords(strapi, {
/* @ts-ignore: disable-next-line */
contentTypes: [getContentTypes()['foo']],
contentTypes: [getContentTypes().foo],
});
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 type { IDestinationProvider, IMetadata, ProviderType } from '../../../types';
import { deleteAllRecords, DeleteOptions } from './restore';
import chalk from 'chalk';
import { Writable } from 'stream';
import path from 'path';
import * as fse from 'fs-extra';
import type { IConfiguration, IDestinationProvider, IMetadata, ProviderType } from '../../../types';
import { deleteAllRecords, DeleteOptions, restoreConfigs } from './restore';
import { mapSchemasValues } from '../../utils';
export const VALID_STRATEGIES = ['restore', 'merge'];
@ -16,10 +17,6 @@ interface ILocalStrapiDestinationProviderOptions {
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 = (
options: ILocalStrapiDestinationProviderOptions
) => {
@ -27,10 +24,12 @@ export const createLocalStrapiDestinationProvider = (
};
class LocalStrapiDestinationProvider implements IDestinationProvider {
name: string = 'destination::local-strapi';
name = 'destination::local-strapi';
type: ProviderType = 'destination';
options: ILocalStrapiDestinationProviderOptions;
strapi?: Strapi.Strapi;
constructor(options: ILocalStrapiDestinationProviderOptions) {
@ -48,7 +47,7 @@ class LocalStrapiDestinationProvider implements IDestinationProvider {
#validateOptions() {
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) {
throw new Error('Strapi instance not found');
}
return await deleteAllRecords(this.strapi, this.options.restore);
return deleteAllRecords(this.strapi, this.options.restore);
}
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 { IConfiguration } from '../../../types';
export type DeleteOptions = {
contentTypes?: ContentTypeSchema[];
@ -33,3 +34,28 @@ export const deleteAllRecords = async (strapi: Strapi.Strapi, deleteOptions?: De
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 type { IEntity } from '../../../../types';
import {
collect,

View File

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

View File

@ -347,7 +347,7 @@ describe('Local Strapi Source Provider - Entities Streaming', () => {
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({
contentTypes: {
blog: {

View File

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

View File

@ -1,7 +1,6 @@
import type { RelationsType } from '@strapi/strapi';
import type { ILink } from '../../../../types';
import { concat, set, isEmpty } from 'lodash/fp';
import type { ILink } from '../../../../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) =>
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);
}
@ -90,6 +89,7 @@ export const parseRelationLinks = ({ entity, schema, fieldName, value }: any): I
const isMorphRelation = relation.startsWith('morph');
const isCircularRelation = !isMorphRelation && target === schema.uid;
// eslint-disable-next-line no-nested-ternary
const kind: ILink['kind'] = isMorphRelation
? // Polymorphic relations
'relation.morph'

View File

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

View File

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

View File

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

View File

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

View File

@ -8,10 +8,15 @@ const {
// eslint-disable-next-line import/no-unresolved, node/no-missing-require
} = require('@strapi/data-transfer');
const { isObject } = require('lodash/fp');
const path = require('path');
const strapi = require('../../index');
const { buildTransferTable } = require('./utils');
/**
* @typedef {import('@strapi/data-transfer').ILocalFileSourceProviderOptions} ILocalFileSourceProviderOptions
*/
const logger = console;
module.exports = async (opts) => {
@ -20,14 +25,12 @@ module.exports = async (opts) => {
logger.error('Could not parse arguments');
process.exit(1);
}
const filename = opts.file;
/**
* From strapi backup file
*/
const sourceOptions = {
backupFilePath: filename,
};
const sourceOptions = getLocalFileSourceOptions(opts);
const source = createLocalFileSourceProvider(sourceOptions);
/**
@ -69,16 +72,50 @@ module.exports = async (opts) => {
const engine = createTransferEngine(source, destination, engineOptions);
try {
logger.log('Starting import...');
logger.info('Starting import...');
const results = await engine.transfer();
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);
} catch (e) {
logger.log(`Import process failed unexpectedly: ${e.message}`);
logger.error(`Import process failed unexpectedly: ${e.message}`);
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
}
}