diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 769a89eba1..5381a13472 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,6 +52,7 @@ The Strapi core team will review your pull request and either merge it, request **Before submitting your pull request** make sure the following requirements are fulfilled: - Fork the repository and create your new branch from `main`. +- Run `yarn install` in the root of the repository. - Run `yarn setup` in the root of the repository. - If you've fixed a bug or added code that should be tested, please make sure to add tests - Ensure the following test suites are passing: @@ -78,6 +79,7 @@ Go to the root of the repository and run the setup: ```bash cd strapi +yarn install yarn setup ``` @@ -183,4 +185,4 @@ Before submitting an issue you need to make sure: - Make sure your application has a clean `node_modules` directory, meaning: - you didn't link any dependencies (e.g., by running `yarn link`) - you haven't made any inline changes to files in the `node_modules` directory - - you don't have any global dependency loops. If you aren't sure, the easiest way to double-check any of the above is to run: `$ rm -rf node_modules && yarn cache clean && yarn setup`. + - you don't have any global dependency loops. If you aren't sure, the easiest way to double-check any of the above is to run: `$ rm -rf node_modules && yarn cache clean && yarn install && yarn setup`. diff --git a/docs/docs/core/admin/link-strapi-design-system.md b/docs/docs/core/admin/link-strapi-design-system.md index f08c5f9519..0d9e0bed93 100644 --- a/docs/docs/core/admin/link-strapi-design-system.md +++ b/docs/docs/core/admin/link-strapi-design-system.md @@ -2,24 +2,18 @@ Follow these steps to use a local version of the Strapi design system with the Strapi monorepo -First, run `yarn build` in `strapi-design-system/packages/strapi-design-system` to generate the bundle. +In your copy of the design system run `yarn build` to generate the bundle. -In your copy of Strapi, you can link the design system using either a [relative path](#relative-path) or [yarn link](#yarn-link). - -### Relative path - -Replace the version number in both `strapi/packages/core/admin/package.json` and `strapi/packages/core/helper-plugin/package.json` with the relative path to your copy of the design system: +In the Strapi monorepo link your local copy of the design system with [`yarn link`](https://yarnpkg.com/cli/link#gatsby-focus-wrapper): ``` -"@strapi/design-system": "link:../../../../strapi-design-system/packages/strapi-design-system" +yarn link -r ../ ``` -### Yarn link +Running yarn build in `examples/getstarted` should now use your local version of the design system. -Alternatively, you can use [`yarn link`](https://classic.yarnpkg.com/lang/en/docs/cli/link/) by first running `yarn link` in `strapi-design-system/packages/design-system` and then `yarn link @strapi/design-system` in both `strapi/packages/core/admin` and `strapi/packages/core/helper-plugin`. With this approach, no changes need to be made to the `package.json` - -Once the link is setup, run the following command from the root of the monorepo +To revert back to the released version of the design system use [`yarn unlink`](https://yarnpkg.com/cli/unlink#usage): ``` -yarn clean && yarn setup +yarn unlink ../ ``` diff --git a/packages/core/admin/admin/src/content-manager/components/RepeatableComponent/components/Component.js b/packages/core/admin/admin/src/content-manager/components/RepeatableComponent/components/Component.js index aca74533b5..249db3fcad 100644 --- a/packages/core/admin/admin/src/content-manager/components/RepeatableComponent/components/Component.js +++ b/packages/core/admin/admin/src/content-manager/components/RepeatableComponent/components/Component.js @@ -97,11 +97,17 @@ const DraggedItem = ({ const accordionRef = useRef(null); const { formatMessage } = useIntl(); - const [parentFieldName] = componentFieldName.split('.'); + /** + * The last item in the fieldName array will be the index of this component. + * Drag and drop should be isolated to the parent component so nested repeatable + * components are not affected by the drag and drop of the parent component in + * their own re-ordering context. + */ + const componentKey = componentFieldName.split('.').slice(0, -1).join('.'); const [{ handlerId, isDragging, handleKeyDown }, boxRef, dropRef, dragRef, dragPreviewRef] = useDragAndDrop(!isReadOnly, { - type: `${ItemTypes.COMPONENT}_${parentFieldName}`, + type: `${ItemTypes.COMPONENT}_${componentKey}`, index, item: { displayedValue, diff --git a/packages/core/admin/admin/src/content-manager/pages/EditSettingsView/components/ModalForm.js b/packages/core/admin/admin/src/content-manager/pages/EditSettingsView/components/ModalForm.js index 3b532666bb..157a11827a 100644 --- a/packages/core/admin/admin/src/content-manager/pages/EditSettingsView/components/ModalForm.js +++ b/packages/core/admin/admin/src/content-manager/pages/EditSettingsView/components/ModalForm.js @@ -102,7 +102,9 @@ const ModalForm = ({ onMetaChange, onSizeChange }) => { ); }); - const { isResizable } = fieldSizes[attributes[selectedField].type]; + // Check for a custom input provided by a custom field, or use the default one for that type + const { type, customField } = attributes[selectedField]; + const { isResizable } = fieldSizes[customField] ?? fieldSizes[type]; const sizeField = ( diff --git a/packages/core/admin/admin/src/content-manager/pages/EditSettingsView/reducer.js b/packages/core/admin/admin/src/content-manager/pages/EditSettingsView/reducer.js index 6b79791202..d9cad8c5f8 100644 --- a/packages/core/admin/admin/src/content-manager/pages/EditSettingsView/reducer.js +++ b/packages/core/admin/admin/src/content-manager/pages/EditSettingsView/reducer.js @@ -30,8 +30,14 @@ const reducer = (state = initialState, action) => } case 'ON_ADD_FIELD': { const newState = cloneDeep(state); - const type = get(newState, ['modifiedData', 'attributes', action.name, 'type'], ''); - const size = action.fieldSizes[type]?.default ?? DEFAULT_FIELD_SIZE; + const attribute = get(newState, ['modifiedData', 'attributes', action.name], {}); + + // Get the default size, checking custom fields first, then the type and generic defaults + const size = + action.fieldSizes[attribute?.customField]?.default ?? + action.fieldSizes[attribute?.type]?.default ?? + DEFAULT_FIELD_SIZE; + const listSize = get(newState, layoutPathEdit, []).length; const actualRowContentPath = [...layoutPathEdit, listSize - 1, 'rowContent']; const rowContentToSet = get(newState, actualRowContentPath, []); diff --git a/packages/core/admin/ee/server/controllers/authentication/middlewares.js b/packages/core/admin/ee/server/controllers/authentication/middlewares.js index deb669844b..1d2e8a7a03 100644 --- a/packages/core/admin/ee/server/controllers/authentication/middlewares.js +++ b/packages/core/admin/ee/server/controllers/authentication/middlewares.js @@ -95,13 +95,14 @@ const redirectWithAuth = (ctx) => { params: { provider }, } = ctx; const redirectUrls = utils.getPrefixedRedirectUrls(); + const domain = strapi.config.get('server.admin.auth.domain'); const { user } = ctx.state; const jwt = getService('token').createJwtToken(user); const isProduction = strapi.config.get('environment') === 'production'; - const cookiesOptions = { httpOnly: false, secure: isProduction, overwrite: true }; + const cookiesOptions = { httpOnly: false, secure: isProduction, overwrite: true, domain }; const sanitizedUser = getService('user').sanitizeUser(user); strapi.eventHub.emit('admin.auth.success', { user: sanitizedUser, provider }); diff --git a/packages/core/content-manager/server/bootstrap.js b/packages/core/content-manager/server/bootstrap.js index 5d12cbc871..99768e9414 100644 --- a/packages/core/content-manager/server/bootstrap.js +++ b/packages/core/content-manager/server/bootstrap.js @@ -6,4 +6,5 @@ module.exports = async () => { await getService('components').syncConfigurations(); await getService('content-types').syncConfigurations(); await getService('permission').registerPermissions(); + getService('field-sizes').setCustomFieldInputSizes(); }; diff --git a/packages/core/content-manager/server/services/__tests__/field-sizes.test.js b/packages/core/content-manager/server/services/__tests__/field-sizes.test.js index 43c79cc101..0b4e5d3780 100644 --- a/packages/core/content-manager/server/services/__tests__/field-sizes.test.js +++ b/packages/core/content-manager/server/services/__tests__/field-sizes.test.js @@ -1,10 +1,36 @@ 'use strict'; -const fieldSizesService = require('../field-sizes'); +const { ApplicationError } = require('@strapi/utils').errors; +const createFieldSizesService = require('../field-sizes'); + +const strapi = { + container: { + // Mock container.get('custom-fields') + get: jest.fn(() => ({ + // Mock container.get('custom-fields').getAll() + getAll: jest.fn(() => ({ + 'plugin::mycustomfields.color': { + name: 'color', + plugin: 'mycustomfields', + type: 'string', + }, + 'plugin::mycustomfields.smallColor': { + name: 'smallColor', + plugin: 'mycustomfields', + type: 'string', + inputSize: { + default: 4, + isResizable: false, + }, + }, + })), + })), + }, +}; describe('field sizes service', () => { it('should return the correct field sizes', () => { - const { getAllFieldSizes } = fieldSizesService(); + const { getAllFieldSizes } = createFieldSizesService({ strapi }); const fieldSizes = getAllFieldSizes(); Object.values(fieldSizes).forEach((fieldSize) => { expect(typeof fieldSize.isResizable).toBe('boolean'); @@ -13,21 +39,42 @@ describe('field sizes service', () => { }); it('should return the correct field size for a given type', () => { - const { getFieldSize } = fieldSizesService(); + const { getFieldSize } = createFieldSizesService({ strapi }); const fieldSize = getFieldSize('string'); expect(fieldSize.isResizable).toBe(true); expect(fieldSize.default).toBe(6); }); it('should throw an error if the type is not found', () => { - const { getFieldSize } = fieldSizesService(); - expect(() => getFieldSize('not-found')).toThrowError( - 'Could not find field size for type not-found' - ); + const { getFieldSize } = createFieldSizesService({ strapi }); + + try { + getFieldSize('not-found'); + } catch (error) { + expect(error instanceof ApplicationError).toBe(true); + expect(error.message).toBe('Could not find field size for type not-found'); + } }); it('should throw an error if the type is not provided', () => { - const { getFieldSize } = fieldSizesService(); - expect(() => getFieldSize()).toThrowError('The type is required'); + const { getFieldSize } = createFieldSizesService({ strapi }); + + try { + getFieldSize(); + } catch (error) { + expect(error instanceof ApplicationError).toBe(true); + expect(error.message).toBe('The type is required'); + } + }); + + it('should set the custom fields input sizes', () => { + const { setCustomFieldInputSizes, getAllFieldSizes } = createFieldSizesService({ strapi }); + setCustomFieldInputSizes(); + const fieldSizes = getAllFieldSizes(); + console.log(fieldSizes); + + expect(fieldSizes).not.toHaveProperty('plugin::mycustomfields.color'); + expect(fieldSizes['plugin::mycustomfields.smallColor'].default).toBe(4); + expect(fieldSizes['plugin::mycustomfields.smallColor'].isResizable).toBe(false); }); }); diff --git a/packages/core/content-manager/server/services/field-sizes.js b/packages/core/content-manager/server/services/field-sizes.js index bdbeffe83f..6f87781f45 100644 --- a/packages/core/content-manager/server/services/field-sizes.js +++ b/packages/core/content-manager/server/services/field-sizes.js @@ -1,5 +1,7 @@ 'use strict'; +const { ApplicationError } = require('@strapi/utils').errors; + const needsFullSize = { default: 12, isResizable: false, @@ -44,20 +46,51 @@ const fieldSizes = { uid: defaultSize, }; -module.exports = () => ({ - getAllFieldSizes() { - return fieldSizes; - }, - getFieldSize(type) { - if (!type) { - throw new Error('The type is required'); - } +const createFieldSizesService = ({ strapi }) => { + const fieldSizesService = { + getAllFieldSizes() { + return fieldSizes; + }, - const fieldSize = fieldSizes[type]; - if (!fieldSize) { - throw new Error(`Could not find field size for type ${type}`); - } + getFieldSize(type) { + if (!type) { + throw new ApplicationError('The type is required'); + } - return fieldSize; - }, -}); + const fieldSize = fieldSizes[type]; + if (!fieldSize) { + throw new ApplicationError(`Could not find field size for type ${type}`); + } + + return fieldSize; + }, + + setFieldSize(type, size) { + if (!type) { + throw new ApplicationError('The type is required'); + } + + if (!size) { + throw new ApplicationError('The size is required'); + } + + fieldSizes[type] = size; + }, + + setCustomFieldInputSizes() { + // Find all custom fields already registered + const customFields = strapi.container.get('custom-fields').getAll(); + + // If they have a custom field size, register it + Object.entries(customFields).forEach(([uid, customField]) => { + if (customField.inputSize) { + fieldSizesService.setFieldSize(uid, customField.inputSize); + } + }); + }, + }; + + return fieldSizesService; +}; + +module.exports = createFieldSizesService; diff --git a/packages/core/content-manager/server/services/utils/configuration/layouts.js b/packages/core/content-manager/server/services/utils/configuration/layouts.js index 18dd5f5c6e..03ce8d2854 100644 --- a/packages/core/content-manager/server/services/utils/configuration/layouts.js +++ b/packages/core/content-manager/server/services/utils/configuration/layouts.js @@ -20,9 +20,18 @@ const isAllowedFieldSize = (type, size) => { return size <= MAX_ROW_SIZE; }; -const getDefaultFieldSize = (type) => { +const getDefaultFieldSize = (attribute) => { + // Check if it's a custom field with a custom size + if (attribute.customField) { + const customField = strapi.container.get('custom-fields').get(attribute.customField); + if (customField.inputSize) { + return customField.inputSize.default; + } + } + + // Get the default size for the field type const { getFieldSize } = getService('field-sizes'); - return getFieldSize(type).default; + return getFieldSize(attribute.type).default; }; async function createDefaultLayouts(schema) { @@ -127,7 +136,7 @@ const appendToEditLayout = (layout = [], keysToAppend, schema) => { for (const key of keysToAppend) { const attribute = schema.attributes[key]; - const attributeSize = getDefaultFieldSize(attribute.type); + const attributeSize = getDefaultFieldSize(attribute); const currenRowSize = rowSize(layout[currentRowIndex]); if (currenRowSize + attributeSize > MAX_ROW_SIZE) { diff --git a/packages/core/data-transfer/src/engine/__tests__/engine.test.ts b/packages/core/data-transfer/src/engine/__tests__/engine.test.ts index b08a5259f2..60d9cb889b 100644 --- a/packages/core/data-transfer/src/engine/__tests__/engine.test.ts +++ b/packages/core/data-transfer/src/engine/__tests__/engine.test.ts @@ -1,4 +1,4 @@ -import { join } from 'path'; +import { posix, win32 } from 'path'; import { cloneDeep } from 'lodash/fp'; import { Readable, Writable } from 'stream-chain'; import type { Schema } from '@strapi/strapi'; @@ -48,13 +48,13 @@ const getAssetsMockSourceStream = ( data: Iterable = [ { filename: 'foo.jpg', - filepath: join(__dirname, 'foo.jpg'), + filepath: posix.join(__dirname, 'foo.jpg'), // test a file with a posix path stats: { size: 24 }, stream: Readable.from([1, 2, 3]), }, { filename: 'bar.jpg', - filepath: join(__dirname, 'bar.jpg'), + filepath: win32.join(__dirname, 'bar.jpg'), // test a file with a win32 path stats: { size: 48 }, stream: Readable.from([4, 5, 6, 7, 8, 9]), }, diff --git a/packages/core/data-transfer/src/file/providers/destination/index.ts b/packages/core/data-transfer/src/file/providers/destination/index.ts index aebec13218..1f6ebd7ebd 100644 --- a/packages/core/data-transfer/src/file/providers/destination/index.ts +++ b/packages/core/data-transfer/src/file/providers/destination/index.ts @@ -249,7 +249,8 @@ class LocalFileDestinationProvider implements IDestinationProvider { return new Writable({ objectMode: true, write(data: IAsset, _encoding, callback) { - const entryPath = path.join('assets', 'uploads', data.filename); + // always write tar files with posix paths so we have a standard format for paths regardless of system + const entryPath = path.posix.join('assets', 'uploads', data.filename); const entry = archiveStream.entry({ name: entryPath, diff --git a/packages/core/data-transfer/src/file/providers/destination/utils.ts b/packages/core/data-transfer/src/file/providers/destination/utils.ts index 7149e589f6..90eef24750 100644 --- a/packages/core/data-transfer/src/file/providers/destination/utils.ts +++ b/packages/core/data-transfer/src/file/providers/destination/utils.ts @@ -1,5 +1,5 @@ import { Writable } from 'stream'; -import { join } from 'path'; +import { posix } from 'path'; import tar from 'tar-stream'; /** @@ -9,7 +9,8 @@ import tar from 'tar-stream'; export const createFilePathFactory = (type: string) => (fileIndex = 0): string => { - return join( + // always write tar files with posix paths so we have a standard format for paths regardless of system + return posix.join( // "{type}" directory type, // "${type}_XXXXX.jsonl" file diff --git a/packages/core/data-transfer/src/file/providers/source/__tests__/index.test.ts b/packages/core/data-transfer/src/file/providers/source/__tests__/index.test.ts index 7de7b2f551..867bc45b61 100644 --- a/packages/core/data-transfer/src/file/providers/source/__tests__/index.test.ts +++ b/packages/core/data-transfer/src/file/providers/source/__tests__/index.test.ts @@ -2,9 +2,10 @@ import { Readable } from 'stream'; import type { ILocalFileSourceProviderOptions } from '..'; import { createLocalFileSourceProvider } from '..'; +import { isFilePathInDirname, isPathEquivalent, unknownPathToPosix } from '../utils'; -describe('Stream assets', () => { - test('returns a stream', () => { +describe('File source provider', () => { + test('returns assets stream', () => { const options: ILocalFileSourceProviderOptions = { file: { path: './test-file', @@ -21,4 +22,112 @@ describe('Stream assets', () => { expect(stream instanceof Readable).toBeTruthy(); }); + + describe('utils', () => { + const unknownConversionCases = [ + ['some/path/on/posix', 'some/path/on/posix'], + ['some/path/on/posix/', 'some/path/on/posix/'], + ['some/path/on/posix.jpg', 'some/path/on/posix.jpg'], + ['file.jpg', 'file.jpg'], + ['noextension', 'noextension'], + ['some\\windows\\filename.jpg', 'some/windows/filename.jpg'], + ['some\\windows\\noendingslash', 'some/windows/noendingslash'], + ['some\\windows\\endingslash\\', 'some/windows/endingslash/'], + ['some\\windows/mixed', 'some\\windows/mixed'], // improper usage resulting in invalid path if provided mixed windows path, but test expected behaviour + ]; + test.each(unknownConversionCases)('unknownPathToPosix: %p -> %p', (input, expected) => { + expect(unknownPathToPosix(input)).toEqual(expected); + }); + + const isFilePathInDirnameCases: [string, string, boolean][] = [ + // posix paths + ['some/path/on/posix', 'some/path/on/posix/file.jpg', true], + ['some/path/on/posix/', 'some/path/on/posix/file.jpg', true], + ['./some/path/on/posix', 'some/path/on/posix/file.jpg', true], + ['some/path/on/posix/', './some/path/on/posix/file.jpg', true], + ['some/path/on/posix/', 'some/path/on/posix/', false], // invalid; second should include a filename + ['some/path/on/posix', 'some/path/on/posix', false], // 'posix' in second should be interpreted as a filename + ['', './file.jpg', true], + ['./', './file.jpg', true], + ['noextension', './noextension/file.jpg', true], + ['./noextension', './noextension/file.jpg', true], + ['./noextension', 'noextension/file.jpg', true], + ['noextension', 'noextension/noextension', true], + // win32 paths + ['some/path/on/win32', 'some\\path\\on\\win32\\file.jpg', true], + ['some/path/on/win32/', 'some\\path\\on\\win32\\file.jpg', true], + ['some/path/on/win32/', 'some\\path\\on\\win32\\', false], // invalid; second should include a filename + ['some/path/on/win32', 'some\\path\\on\\win32', false], // 'win32' in second should be interpreted as a filename + ['', '.\\file.jpg', true], + ['./', '.\\file.jpg', true], + ['noextension', '.\\noextension\\file.jpg', true], + ['./noextension', '.\\noextension\\file.jpg', true], + ['./noextension', 'noextension\\file.jpg', true], + ['noextension', 'noextension\\noextension', true], + // no path structure + ['', 'file.jpg', true], + ['noextension', 'noextension', false], // second case is a file + ]; + test.each(isFilePathInDirnameCases)( + 'isFilePathInDirname: %p : %p -> %p', + (inputA, inputB, expected) => { + expect(isFilePathInDirname(inputA, inputB)).toEqual(expected); + } + ); + + const isPathEquivalentCases: [string, string, boolean][] = [ + // POSITIVES + // root level + ['file.jpg', 'file.jpg', true], + ['file.jpg', '.\\file.jpg', true], + ['file.jpg', './file.jpg', true], + // cwd root level (posix) + ['./file.jpg', 'file.jpg', true], + ['./file.jpg', './file.jpg', true], + ['./file.jpg', '.\\file.jpg', true], + // cwd root level (win32) + ['.\\file.jpg', 'file.jpg', true], + ['.\\file.jpg', './file.jpg', true], + ['.\\file.jpg', '.\\file.jpg', true], + // directory with file (posix) + ['one/two/file.jpg', 'one/two/file.jpg', true], + ['one/two/file.jpg', './one/two/file.jpg', true], + ['one/two/file.jpg', 'one\\two\\file.jpg', true], + ['one/two/file.jpg', '.\\one\\two\\file.jpg', true], + // cwd with file (posix) + ['./one/two/file.jpg', 'one/two/file.jpg', true], + ['./one/two/file.jpg', './one/two/file.jpg', true], + ['./one/two/file.jpg', 'one\\two\\file.jpg', true], + ['./one/two/file.jpg', '.\\one\\two\\file.jpg', true], + // directory with file (win32) + ['one\\two\\file.jpg', 'one/two/file.jpg', true], + ['one\\two\\file.jpg', './one/two/file.jpg', true], + ['one\\two\\file.jpg', '.\\one\\two\\file.jpg', true], + ['one\\two\\file.jpg', 'one\\two\\file.jpg', true], + // cwd with file (win32) + ['.\\one\\two\\file.jpg', 'one/two/file.jpg', true], + ['.\\one\\two\\file.jpg', './one/two/file.jpg', true], + ['.\\one\\two\\file.jpg', '.\\one\\two\\file.jpg', true], + ['.\\one\\two\\file.jpg', 'one\\two\\file.jpg', true], + // special characters + [".\\one\\two\\fi ' ^&*() le.jpg", "one/two/fi ' ^&*() le.jpg", true], // valid characters on win32 + ['test/backslash\\file.jpg', 'test/backslash\\file.jpg', true], // backlash is valid on posix but not win32 + + // NEGATIVES + ['file.jpg', 'one/file.jpg', false], + ['file.jpg', 'one\\file.jpg', false], + ['file.jpg', '/file.jpg', false], + ['file.jpg', '\\file.jpg', false], + ['one/file.jpg', '\\one\\file.jpg', false], + ['one/file.jpg', '/one/file.jpg', false], + ['one/file.jpg', 'file.jpg', false], + ['test/mixedslash\\file.jpg', 'test/mixedslash/file.jpg', false], // windows path with mixed path separators should fail + ]; + test.each(isPathEquivalentCases)( + 'isPathEquivalent: %p : %p -> %p', + (inputA, inputB, expected) => { + expect(isPathEquivalent(inputA, inputB)).toEqual(expected); + } + ); + }); }); diff --git a/packages/core/data-transfer/src/file/providers/source/index.ts b/packages/core/data-transfer/src/file/providers/source/index.ts index 6172f39ba0..80c993f1cc 100644 --- a/packages/core/data-transfer/src/file/providers/source/index.ts +++ b/packages/core/data-transfer/src/file/providers/source/index.ts @@ -4,7 +4,7 @@ import fs from 'fs-extra'; import zip from 'zlib'; import tar from 'tar'; import path from 'path'; -import { keyBy } from 'lodash/fp'; +import { isEmpty, keyBy } from 'lodash/fp'; import { chain } from 'stream-chain'; import { pipeline, PassThrough } from 'stream'; import { parser } from 'stream-json/jsonl/Parser'; @@ -15,6 +15,7 @@ import type { IAsset, IMetadata, ISourceProvider, ProviderType } from '../../../ import { createDecryptionCipher } from '../../../utils/encryption'; import { collect } from '../../../utils/stream'; import { ProviderInitializationError, ProviderTransferError } from '../../../errors/providers'; +import { isFilePathInDirname, isPathEquivalent, unknownPathToPosix } from './utils'; type StreamItemArray = Parameters[0]; @@ -72,7 +73,8 @@ class LocalFileSourceProvider implements ISourceProvider { try { // Read the metadata to ensure the file can be parsed - this.#metadata = await this.getMetadata(); + await this.#loadMetadata(); + // TODO: we might also need to read the schema.jsonl files & implements a custom stream-check } catch (e) { if (this.options?.encryption?.enabled) { throw new ProviderInitializationError( @@ -81,18 +83,32 @@ class LocalFileSourceProvider implements ISourceProvider { } throw new ProviderInitializationError(`File '${filePath}' is not a valid Strapi data file.`); } + + if (!this.#metadata) { + throw new ProviderInitializationError('Could not load metadata from Strapi data file.'); + } } - getMetadata() { - // TODO: need to read the file & extract the metadata json file - // => we might also need to read the schema.jsonl files & implements a custom stream-check + async #loadMetadata() { const backupStream = this.#getBackupStream(); - return this.#parseJSONFile(backupStream, METADATA_FILE_PATH); + this.#metadata = await this.#parseJSONFile(backupStream, METADATA_FILE_PATH); + } + + async getMetadata() { + if (!this.#metadata) { + await this.#loadMetadata(); + } + + return this.#metadata ?? null; } async getSchemas() { const schemas = await collect(this.createSchemasReadStream()); + if (isEmpty(schemas)) { + throw new ProviderInitializationError('Could not load schemas from Strapi data file.'); + } + return keyBy('uid', schemas); } @@ -121,20 +137,21 @@ class LocalFileSourceProvider implements ISourceProvider { [ inStream, new tar.Parse({ + // find only files in the assets/uploads folder filter(filePath, entry) { if (entry.type !== 'File') { return false; } - - const parts = filePath.split('/'); - return parts[0] === 'assets' && parts[1] === 'uploads'; + return isFilePathInDirname('assets/uploads', filePath); }, onentry(entry) { const { path: filePath, size = 0 } = entry; - const file = path.basename(filePath); + const normalizedPath = unknownPathToPosix(filePath); + const file = path.basename(normalizedPath); + const asset: IAsset = { filename: file, - filepath: filePath, + filepath: normalizedPath, stats: { size }, stream: entry as unknown as Readable, }; @@ -170,6 +187,7 @@ class LocalFileSourceProvider implements ISourceProvider { return chain(streams); } + // `directory` must be posix formatted path #streamJsonlDirectory(directory: string) { const inStream = this.#getBackupStream(); @@ -184,14 +202,7 @@ class LocalFileSourceProvider implements ISourceProvider { return false; } - const parts = path.relative('.', filePath).split('/'); - - // TODO: this method is limiting us from having additional subdirectories and is requiring us to remove any "./" prefixes (the path.relative line above) - if (parts.length !== 2) { - return false; - } - - return parts[0] === directory; + return isFilePathInDirname(directory, filePath); }, async onentry(entry) { @@ -249,7 +260,11 @@ class LocalFileSourceProvider implements ISourceProvider { * Filter the parsed entries to only keep the one that matches the given filepath */ filter(entryPath, entry) { - return !path.relative(filePath, entryPath).length && entry.type === 'File'; + if (entry.type !== 'File') { + return false; + } + + return isPathEquivalent(entryPath, filePath); }, async onentry(entry) { diff --git a/packages/core/data-transfer/src/file/providers/source/utils.ts b/packages/core/data-transfer/src/file/providers/source/utils.ts new file mode 100644 index 0000000000..60111f5da3 --- /dev/null +++ b/packages/core/data-transfer/src/file/providers/source/utils.ts @@ -0,0 +1,58 @@ +import path from 'path'; + +/** + * Note: in versions of the transfer engine <=4.9.0, exports were generated with windows paths + * on Windows systems, and posix paths on posix systems. + * + * We now store all paths as posix, but need to leave a separator conversion for legacy purposes, and to + * support manually-created tar files coming from Windows systems (ie, if a user creates a + * backup file with a windows tar tool rather than using the `export` command) + * + * Because of this, export/import files may never contain files with a forward slash in the name, even escaped + * + * */ + +/** + * Check if the directory of a given filePath (which can be either posix or win32) resolves to the same as the given posix-format path posixDirName + * We must be able to assume the first argument is a path to a directory and the second is a path to a file, otherwise path.dirname will interpret a path without any slashes as the filename + * + * @param {string} posixDirName A posix path pointing to a directory + * @param {string} filePath an unknown filesystem path pointing to a file + * @returns {boolean} is the file located in the given directory + */ +export const isFilePathInDirname = (posixDirName: string, filePath: string) => { + const normalizedDir = path.posix.dirname(unknownPathToPosix(filePath)); + return isPathEquivalent(posixDirName, normalizedDir); +}; + +/** + * Check if two paths that can be either in posix or win32 format resolves to the same file + * + * @param {string} pathA a path that may be either win32 or posix + * @param {string} pathB a path that may be either win32 or posix + * + * @returns {boolean} do paths point to the same place + */ +export const isPathEquivalent = (pathA: string, pathB: string) => { + // Check if paths appear to be win32 or posix, and if win32 convert to posix + const normalizedPathA = path.posix.normalize(unknownPathToPosix(pathA)); + const normalizedPathB = path.posix.normalize(unknownPathToPosix(pathB)); + + return !path.posix.relative(normalizedPathB, normalizedPathA).length; +}; + +/** + * Convert an unknown format path (win32 or posix) to a posix path + * + * @param {string} filePath a path that may be either win32 or posix + * + * @returns {string} a posix path + */ +export const unknownPathToPosix = (filePath: string) => { + // if it includes a forward slash, it must be posix already -- we will not support win32 with mixed path separators + if (filePath.includes(path.posix.sep)) { + return filePath; + } + + return path.normalize(filePath).split(path.win32.sep).join(path.posix.sep); +}; diff --git a/packages/core/database/lib/entity-manager/regular-relations.js b/packages/core/database/lib/entity-manager/regular-relations.js index 33f780330d..1f90714b1e 100644 --- a/packages/core/database/lib/entity-manager/regular-relations.js +++ b/packages/core/database/lib/entity-manager/regular-relations.js @@ -152,7 +152,7 @@ const deleteRelations = async ({ .transacting(trx) .execute(); done = batchToDelete.length < batchSize; - lastId = batchToDelete[batchToDelete.length - 1]?.id; + lastId = batchToDelete[batchToDelete.length - 1]?.id || 0; const batchIds = map(inverseJoinColumn.name, batchToDelete); diff --git a/packages/core/database/lib/metadata/relations.js b/packages/core/database/lib/metadata/relations.js index dbafdb9f45..ee662b2ec6 100644 --- a/packages/core/database/lib/metadata/relations.js +++ b/packages/core/database/lib/metadata/relations.js @@ -244,6 +244,16 @@ const createMorphToMany = (attributeName, attribute, meta, metadata) => { name: `${joinTableName}_fk`, columns: [joinColumnName], }, + { + name: `${joinTableName}_order_index`, + columns: ['order'], + type: null, + }, + { + name: `${joinTableName}_id_column_index`, + columns: [idColumnName], + type: null, + }, ], foreignKeys: [ { diff --git a/packages/core/strapi/lib/core/registries/__tests__/custom-fields.test.js b/packages/core/strapi/lib/core/registries/__tests__/custom-fields.test.js index fd45f4346d..938968d129 100644 --- a/packages/core/strapi/lib/core/registries/__tests__/custom-fields.test.js +++ b/packages/core/strapi/lib/core/registries/__tests__/custom-fields.test.js @@ -90,6 +90,28 @@ describe('Custom fields registry', () => { ); }); + it('validates inputSize', () => { + const mockCF = { + name: 'test', + type: 'text', + }; + + const customFields = customFieldsRegistry(strapi); + + expect(() => customFields.add({ ...mockCF, inputSize: 'small' })).toThrowError( + `inputSize should be an object with 'default' and 'isResizable' keys` + ); + expect(() => customFields.add({ ...mockCF, inputSize: ['array'] })).toThrowError( + `inputSize should be an object with 'default' and 'isResizable' keys` + ); + expect(() => + customFields.add({ ...mockCF, inputSize: { default: 99, isResizable: true } }) + ).toThrowError('Custom fields require a valid default input size'); + expect(() => + customFields.add({ ...mockCF, inputSize: { default: 12, isResizable: 'true' } }) + ).toThrowError('Custom fields should specify if their input is resizable'); + }); + it('confirms the custom field does not already exist', () => { const mockCF = { name: 'test', diff --git a/packages/core/strapi/lib/core/registries/custom-fields.js b/packages/core/strapi/lib/core/registries/custom-fields.js index 47ad2eaf08..a911819e75 100644 --- a/packages/core/strapi/lib/core/registries/custom-fields.js +++ b/packages/core/strapi/lib/core/registries/custom-fields.js @@ -1,6 +1,6 @@ 'use strict'; -const { has } = require('lodash/fp'); +const { has, isPlainObject } = require('lodash/fp'); const ALLOWED_TYPES = [ 'biginteger', @@ -44,7 +44,7 @@ const customFieldsRegistry = (strapi) => { throw new Error(`Custom fields require a 'name' and 'type' key`); } - const { name, plugin, type } = cf; + const { name, plugin, type, inputSize } = cf; if (!ALLOWED_TYPES.includes(type)) { throw new Error( `Custom field type: '${type}' is not a valid Strapi type or it can't be used with a Custom Field` @@ -56,6 +56,23 @@ const customFieldsRegistry = (strapi) => { throw new Error(`Custom field name: '${name}' is not a valid object key`); } + // Validate inputSize when provided + if (inputSize) { + if ( + !isPlainObject(inputSize) || + !has('default', inputSize) || + !has('isResizable', inputSize) + ) { + throw new Error(`inputSize should be an object with 'default' and 'isResizable' keys`); + } + if (![4, 6, 8, 12].includes(inputSize.default)) { + throw new Error('Custom fields require a valid default input size'); + } + if (typeof inputSize.isResizable !== 'boolean') { + throw new Error('Custom fields should specify if their input is resizable'); + } + } + // When no plugin is specified, or it isn't found in Strapi, default to global const uid = strapi.plugin(plugin) ? `plugin::${plugin}.${name}` : `global::${name}`; diff --git a/packages/providers/upload-aws-s3/src/index.ts b/packages/providers/upload-aws-s3/src/index.ts index 5cfdd3ecda..5b886b06ea 100644 --- a/packages/providers/upload-aws-s3/src/index.ts +++ b/packages/providers/upload-aws-s3/src/index.ts @@ -27,7 +27,7 @@ interface File { // eslint-disable-next-line @typescript-eslint/no-var-requires require('aws-sdk/lib/maintenance_mode_message').suppress = true; -function assertUrlProtocol(url: string) { +function hasUrlProtocol(url: string) { // Regex to test protocol like "http://", "https://" return /^\w*:\/\//.test(url); } @@ -93,11 +93,13 @@ export = { } // set the bucket file url - if (assertUrlProtocol(data.Location)) { - file.url = baseUrl ? `${baseUrl}/${fileKey}` : data.Location; + if (baseUrl) { + // Construct the url with the baseUrl + file.url = `${baseUrl}/${fileKey}`; } else { - // Default protocol to https protocol - file.url = `https://${data.Location}`; + // Add the protocol if it is missing + // Some providers like DigitalOcean Spaces return the url without the protocol + file.url = hasUrlProtocol(data.Location) ? data.Location : `https://${data.Location}`; } resolve(); }; diff --git a/vercel.json b/vercel.json index 77f6c2a272..3d53349522 100644 --- a/vercel.json +++ b/vercel.json @@ -1,10 +1,6 @@ { - "buildCommand": "cd packages/core/helper-plugin && yarn build-storybook", "github": { "silent": true, "autoJobCancelation": true - }, - "installCommand": "yarn install --immutable", - "ignoreCommand": "git diff HEAD^ HEAD --quiet './packages/core/helper-plugin'", - "outputDirectory": "packages/core/helper-plugin/storybook-static" + } }