diff --git a/examples/getstarted/config/middlewares.js b/examples/getstarted/config/middlewares.js index ed9d30471a..c7b6ae467a 100644 --- a/examples/getstarted/config/middlewares.js +++ b/examples/getstarted/config/middlewares.js @@ -12,6 +12,8 @@ module.exports = [ useDefaults: true, directives: { 'frame-src': ["'self'"], // URLs that will be loaded in an iframe (e.g. Content Preview) + // Needed to load the `@vercel/stega` lib on the dummy-preview page + 'script-src': ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], }, }, }, diff --git a/examples/getstarted/src/admin/app.jsx b/examples/getstarted/src/admin/app.jsx index 9cd255bcbf..2b4d161977 100644 --- a/examples/getstarted/src/admin/app.jsx +++ b/examples/getstarted/src/admin/app.jsx @@ -7,19 +7,10 @@ import { registerPreviewRoute } from './preview'; const config = { locales: ['it', 'es', 'en', 'en-GB'], }; -const bootstrap = (app) => { - app.getPlugin('content-manager').injectComponent('editView', 'right-links', { - name: 'PreviewButton', - Component: () => ( - - ), - }); -}; export default { config, register: (app) => { registerPreviewRoute(app); }, - bootstrap, }; diff --git a/examples/getstarted/src/admin/preview/dummy-preview.jsx b/examples/getstarted/src/admin/preview/dummy-preview.jsx index 2b83be2963..b0fcb95fa8 100644 --- a/examples/getstarted/src/admin/preview/dummy-preview.jsx +++ b/examples/getstarted/src/admin/preview/dummy-preview.jsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { useParams, useLoaderData, useRevalidator } from 'react-router-dom'; -// @ts-ignore import { Page, Layouts } from '@strapi/admin/strapi-admin'; import { Grid, Flex, Typography, JSONInput, Box } from '@strapi/design-system'; @@ -130,7 +129,7 @@ const PreviewComponent = () => { {key} - + {typeof value === 'object' && value !== null ? JSON.stringify(value, null, 2) : String(value)} diff --git a/examples/getstarted/src/admin/preview/index.jsx b/examples/getstarted/src/admin/preview/index.jsx index a34766f8c7..84ccab1cb6 100644 --- a/examples/getstarted/src/admin/preview/index.jsx +++ b/examples/getstarted/src/admin/preview/index.jsx @@ -25,6 +25,7 @@ const previewLoader = async ({ params }) => { const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${apiToken}`, + 'strapi-encode-source-maps': 'true', }; const searchParams = new URLSearchParams({ locale, diff --git a/packages/core/content-manager/admin/src/preview/utils/previewScript.ts b/packages/core/content-manager/admin/src/preview/utils/previewScript.ts index 9240f2843a..378fe30c4f 100644 --- a/packages/core/content-manager/admin/src/preview/utils/previewScript.ts +++ b/packages/core/content-manager/admin/src/preview/utils/previewScript.ts @@ -4,6 +4,7 @@ declare global { __strapi_previewCleanup?: () => void; STRAPI_HIGHLIGHT_HOVER_COLOR?: string; STRAPI_HIGHLIGHT_ACTIVE_COLOR?: string; + STRAPI_DISABLE_STEGA_DECODING?: boolean; } } @@ -21,6 +22,7 @@ const previewScript = (shouldRun = true) => { const HIGHLIGHT_HOVER_COLOR = window.STRAPI_HIGHLIGHT_HOVER_COLOR ?? '#4945ff'; // dark primary500 const HIGHLIGHT_ACTIVE_COLOR = window.STRAPI_HIGHLIGHT_ACTIVE_COLOR ?? '#7b79ff'; // dark primary600 + const DISABLE_STEGA_DECODING = window.STRAPI_DISABLE_STEGA_DECODING ?? false; const SOURCE_ATTRIBUTE = 'data-strapi-source'; const OVERLAY_ID = 'strapi-preview-overlay'; const INTERNAL_EVENTS = { @@ -54,6 +56,36 @@ const previewScript = (shouldRun = true) => { * Functionality pieces * ---------------------------------------------------------------------------------------------*/ + const setupStegaDecoding = async () => { + if (DISABLE_STEGA_DECODING) { + return; + } + + const { vercelStegaDecode: stegaDecode } = await import( + // @ts-expect-error it's not a local dependency + // eslint-disable-next-line import/no-unresolved + 'https://cdn.jsdelivr.net/npm/@vercel/stega@0.1.2/+esm' + ); + + const allElements = document.querySelectorAll('*'); + + Array.from(allElements).forEach((element) => { + const directTextContent = Array.from(element.childNodes) + .filter((node) => node.nodeType === Node.TEXT_NODE) + .map((node) => node.textContent || '') + .join(''); + + if (directTextContent) { + try { + const result = stegaDecode(directTextContent); + if (result) { + element.setAttribute(SOURCE_ATTRIBUTE, result.key); + } + } catch (error) {} + } + }); + }; + const createOverlaySystem = () => { // Clean up before creating a new overlay so we can safely call previewScript multiple times window.__strapi_previewCleanup?.(); @@ -121,12 +153,12 @@ const previewScript = (shouldRun = true) => { // Move hover detection to the underlying element const mouseEnterHandler = () => { - if (!highlightManager.focusedHighlights.includes(highlight)) { + if (!focusedHighlights.includes(highlight)) { highlight.style.outlineColor = HIGHLIGHT_HOVER_COLOR; } }; const mouseLeaveHandler = () => { - if (!highlightManager.focusedHighlights.includes(highlight)) { + if (!focusedHighlights.includes(highlight)) { highlight.style.outlineColor = 'transparent'; } }; @@ -340,11 +372,13 @@ const previewScript = (shouldRun = true) => { * Orchestration * ---------------------------------------------------------------------------------------------*/ - const overlay = createOverlaySystem(); - const highlightManager = createHighlightManager(overlay); - const observers = setupObservers(highlightManager); - const eventHandlers = setupEventHandlers(highlightManager); - createCleanupSystem(overlay, observers, eventHandlers); + setupStegaDecoding().then(() => { + const overlay = createOverlaySystem(); + const highlightManager = createHighlightManager(overlay); + const observers = setupObservers(highlightManager); + const eventHandlers = setupEventHandlers(highlightManager); + createCleanupSystem(overlay, observers, eventHandlers); + }); }; export { previewScript }; diff --git a/packages/core/core/package.json b/packages/core/core/package.json index 8ab2ae4ece..860d53bbc6 100644 --- a/packages/core/core/package.json +++ b/packages/core/core/package.json @@ -64,6 +64,7 @@ "@strapi/types": "5.23.0", "@strapi/typescript-utils": "5.23.0", "@strapi/utils": "5.23.0", + "@vercel/stega": "0.1.2", "bcryptjs": "2.4.3", "boxen": "5.1.2", "chalk": "4.1.2", diff --git a/packages/core/core/src/Strapi.ts b/packages/core/core/src/Strapi.ts index c12a6df534..5a430a9ed6 100644 --- a/packages/core/core/src/Strapi.ts +++ b/packages/core/core/src/Strapi.ts @@ -32,6 +32,7 @@ import getNumberOfDynamicZones from './services/utils/dynamic-zones'; import getNumberOfConditionalFields from './services/utils/conditional-fields'; import { FeaturesService, createFeaturesService } from './services/features'; import { createDocumentService } from './services/document-service'; +import { createContentSourceMapsService } from './services/content-source-maps'; import { coreStoreModel } from './services/core-store'; import { createConfigProvider } from './services/config'; @@ -287,7 +288,8 @@ class Strapi extends Container implements Core.Strapi { }) ); }) - .add('reload', () => createReloader(this)); + .add('reload', () => createReloader(this)) + .add('content-source-maps', () => createContentSourceMapsService(this)); } sendStartupTelemetry() { diff --git a/packages/core/core/src/core-api/controller/__tests__/transform.test.ts b/packages/core/core/src/core-api/controller/__tests__/transform.test.ts index e25ba8e212..ff08afee1b 100644 --- a/packages/core/core/src/core-api/controller/__tests__/transform.test.ts +++ b/packages/core/core/src/core-api/controller/__tests__/transform.test.ts @@ -2,7 +2,7 @@ import type { Schema } from '@strapi/types'; import * as transforms from '../transform'; describe('Transforms', () => { - test('v4 - using json api format', () => { + test('v4 - using json api format', async () => { const contentType: Schema.ContentType = { globalId: 'test', kind: 'collectionType', @@ -62,7 +62,7 @@ describe('Transforms', () => { } as any; expect( - transforms.transformResponse( + await transforms.transformResponse( { id: 1, documentId: 'abcd', @@ -133,40 +133,40 @@ describe('Transforms', () => { }); }); - test('Leaves nil values untouched', () => { - expect(transforms.transformResponse(undefined)).toBeUndefined(); - expect(transforms.transformResponse(null)).toBeNull(); + test('Leaves nil values untouched', async () => { + expect(await transforms.transformResponse(undefined)).toBeUndefined(); + expect(await transforms.transformResponse(null)).toBeNull(); }); - test('Throws if entry is not and object or an array of object', () => { - expect(() => transforms.transformResponse(0)).toThrow(); - expect(() => transforms.transformResponse(new Date())).toThrow(); - expect(() => transforms.transformResponse('azaz')).toThrow(); + test('Throws if entry is not and object or an array of object', async () => { + await expect(() => transforms.transformResponse(0)).rejects.toThrow(); + await expect(() => transforms.transformResponse(new Date())).rejects.toThrow(); + await expect(() => transforms.transformResponse('azaz')).rejects.toThrow(); }); - test('Handles arrays of entries', () => { - expect(transforms.transformResponse([{ id: 1, title: 'Hello' }])).toStrictEqual({ + test('Handles arrays of entries', async () => { + expect(await transforms.transformResponse([{ id: 1, title: 'Hello' }])).toStrictEqual({ data: [{ id: 1, title: 'Hello' }], meta: {}, }); }); - test('Handles single entry', () => { - expect(transforms.transformResponse({ id: 1, title: 'Hello' })).toStrictEqual({ + test('Handles single entry', async () => { + expect(await transforms.transformResponse({ id: 1, title: 'Hello' })).toStrictEqual({ data: { id: 1, title: 'Hello' }, meta: {}, }); }); - test('Accepts any meta', () => { + test('Accepts any meta', async () => { const someMeta = { foo: 'bar' }; - expect(transforms.transformResponse({ id: 1, title: 'Hello' }, someMeta)).toStrictEqual({ + expect(await transforms.transformResponse({ id: 1, title: 'Hello' }, someMeta)).toStrictEqual({ data: { id: 1, title: 'Hello' }, meta: someMeta, }); }); - test('Handles relations single value', () => { + test('Handles relations single value', async () => { const contentType: Schema.ContentType = { globalId: 'test', kind: 'collectionType', @@ -194,7 +194,7 @@ describe('Transforms', () => { } as any; expect( - transforms.transformResponse( + await transforms.transformResponse( { id: 1, title: 'Hello', relation: { id: 1, value: 'test' } }, undefined, { contentType } @@ -212,7 +212,7 @@ describe('Transforms', () => { }); }); - test('Handles relations array value', () => { + test('Handles relations array value', async () => { const contentType: Schema.ContentType = { globalId: 'test', kind: 'collectionType', @@ -240,7 +240,7 @@ describe('Transforms', () => { } as any; expect( - transforms.transformResponse( + await transforms.transformResponse( { id: 1, title: 'Hello', relation: [{ id: 1, value: 'test' }] }, undefined, { contentType } @@ -260,7 +260,7 @@ describe('Transforms', () => { }); }); - test('Handles relations recursively', () => { + test('Handles relations recursively', async () => { const contentType: Schema.ContentType = { globalId: 'test', kind: 'collectionType', @@ -295,7 +295,7 @@ describe('Transforms', () => { } as any; expect( - transforms.transformResponse( + await transforms.transformResponse( { id: 1, title: 'Hello', @@ -323,7 +323,7 @@ describe('Transforms', () => { }); }); - test('Handles media like relations', () => { + test('Handles media like relations', async () => { const contentType: Schema.ContentType = { globalId: 'test', kind: 'collectionType', @@ -349,7 +349,7 @@ describe('Transforms', () => { } as any; expect( - transforms.transformResponse( + await transforms.transformResponse( { id: 1, title: 'Hello', media: [{ id: 1, value: 'test' }] }, undefined, { contentType } @@ -369,7 +369,7 @@ describe('Transforms', () => { }); }); - test('Handles components & dynamic zones', () => { + test('Handles components & dynamic zones', async () => { const contentType: Schema.ContentType = { globalId: 'test', kind: 'collectionType', @@ -409,7 +409,7 @@ describe('Transforms', () => { } as any; expect( - transforms.transformResponse( + await transforms.transformResponse( { id: 1, title: 'Hello', diff --git a/packages/core/core/src/core-api/controller/index.ts b/packages/core/core/src/core-api/controller/index.ts index 36b3a90ba4..974a0fefd6 100644 --- a/packages/core/core/src/core-api/controller/index.ts +++ b/packages/core/core/src/core-api/controller/index.ts @@ -32,6 +32,7 @@ function createController({ return transformResponse(data, meta, { contentType, useJsonAPIFormat: ctx?.headers?.['strapi-response-format'] === 'v4', + encodeSourceMaps: ctx?.headers?.['strapi-encode-source-maps'] === 'true', }); }, diff --git a/packages/core/core/src/core-api/controller/transform.ts b/packages/core/core/src/core-api/controller/transform.ts index 23c64c8e50..09b93de35e 100644 --- a/packages/core/core/src/core-api/controller/transform.ts +++ b/packages/core/core/src/core-api/controller/transform.ts @@ -1,5 +1,6 @@ import { isNil, isPlainObject } from 'lodash/fp'; import type { UID, Struct, Data } from '@strapi/types'; +import { async } from '@strapi/utils'; type TransformedEntry = { id: string; @@ -32,13 +33,15 @@ interface TransformOptions { * @deprecated this option is deprecated and will be removed in the next major version */ useJsonAPIFormat?: boolean; + encodeSourceMaps?: boolean; } -const transformResponse = ( +const transformResponse = async ( resource: any, meta: unknown = {}, opts: TransformOptions = { useJsonAPIFormat: false, + encodeSourceMaps: false, } ) => { if (isNil(resource)) { @@ -49,8 +52,20 @@ const transformResponse = ( throw new Error('Entry must be an object or an array of objects'); } + // Transform pipeline functions + const applyJsonApiFormat = async (data: any) => + opts.useJsonAPIFormat ? transformEntry(data, opts?.contentType) : data; + + const applySourceMapEncoding = async (data: any) => + opts.encodeSourceMaps && opts.contentType + ? strapi.get('content-source-maps').encodeSourceMaps({ data, schema: opts.contentType }) + : data; + + // Process data through transformation pipeline + const data = await async.pipe(applyJsonApiFormat, applySourceMapEncoding)(resource); + return { - data: opts.useJsonAPIFormat ? transformEntry(resource, opts?.contentType) : resource, + data, meta, }; }; diff --git a/packages/core/core/src/services/content-source-maps.ts b/packages/core/core/src/services/content-source-maps.ts new file mode 100644 index 0000000000..f8b4e974cd --- /dev/null +++ b/packages/core/core/src/services/content-source-maps.ts @@ -0,0 +1,102 @@ +import { vercelStegaCombine } from '@vercel/stega'; +import type { Core, Struct, UID } from '@strapi/types'; +import { traverseEntity } from '@strapi/utils'; + +const ENCODABLE_TYPES = [ + 'string', + 'text', + 'richtext', + 'biginteger', + 'date', + 'time', + 'datetime', + 'timestamp', + 'boolean', + 'enumeration', + 'json', + 'media', + 'email', + 'password', + 'uid', + /** + * We cannot modify the response shape, so types that aren't based on string cannot be encoded: + * - json: object + * - blocks: object, will require a custom implementation in a dedicated PR + * - integer, float and decimal: number + * - boolean: boolean (believe it or not) + */ +]; + +// TODO: use a centralized store for these fields that would be shared with the CM and CTB +const EXCLUDED_FIELDS = [ + 'id', + 'documentId', + 'locale', + 'localizations', + 'created_by', + 'updated_by', + 'created_at', + 'updated_at', + 'publishedAt', +]; + +interface EncodingInfo { + data: any; + schema: Struct.Schema; +} + +const createContentSourceMapsService = (strapi: Core.Strapi) => { + return { + encodeField(text: string, key: string): string { + const res = vercelStegaCombine(text, { + // TODO: smarter metadata than just the key + key, + }); + return res; + }, + + async encodeEntry({ data, schema }: EncodingInfo): Promise { + if (typeof data !== 'object' || data === null || data === undefined) { + return data; + } + + return traverseEntity( + ({ key, value, attribute }, { set }) => { + if (!attribute || EXCLUDED_FIELDS.includes(key)) { + return; + } + + if (ENCODABLE_TYPES.includes(attribute.type) && typeof value === 'string') { + set(key, this.encodeField(value, key) as any); + } + }, + { + schema, + getModel: (uid) => strapi.getModel(uid as UID.Schema), + }, + data + ); + }, + + async encodeSourceMaps({ data, schema }: EncodingInfo): Promise { + try { + if (Array.isArray(data)) { + return await Promise.all( + data.map((item) => this.encodeSourceMaps({ data: item, schema })) + ); + } + + if (typeof data !== 'object' || data === null) { + return data; + } + + return await this.encodeEntry({ data, schema }); + } catch (error) { + strapi.log.error('Error encoding source maps:', error); + return data; + } + }, + }; +}; + +export { createContentSourceMapsService }; diff --git a/packages/core/utils/src/sanitize/visitors/remove-restricted-relations.ts b/packages/core/utils/src/sanitize/visitors/remove-restricted-relations.ts index d89bfdaea7..28475e363f 100644 --- a/packages/core/utils/src/sanitize/visitors/remove-restricted-relations.ts +++ b/packages/core/utils/src/sanitize/visitors/remove-restricted-relations.ts @@ -24,6 +24,10 @@ export default (auth: unknown): Visitor => const handleMorphRelation = async () => { const elements: any = (data as Record)[key]; + if (!elements) { + return; + } + if ('connect' in elements || 'set' in elements || 'disconnect' in elements) { const newValue: Record = {}; diff --git a/yarn.lock b/yarn.lock index b6cec9bdce..e57e557599 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8955,6 +8955,7 @@ __metadata: "@types/node": "npm:18.19.24" "@types/node-schedule": "npm:2.1.7" "@types/statuses": "npm:2.0.1" + "@vercel/stega": "npm:0.1.2" bcryptjs: "npm:2.4.3" boxen: "npm:5.1.2" chalk: "npm:4.1.2" @@ -12071,6 +12072,13 @@ __metadata: languageName: node linkType: hard +"@vercel/stega@npm:0.1.2": + version: 0.1.2 + resolution: "@vercel/stega@npm:0.1.2" + checksum: 10c0/66eb80f286d46806004a2eacd215af80ad3cd443e7a96a6070d390dbb3f4d1f6e063013ec0d196c7fe852721d5dbe62e23b44c32975e36b18cda30e5e0728e04 + languageName: node + linkType: hard + "@vitejs/plugin-react-swc@npm:3.6.0": version: 3.6.0 resolution: "@vitejs/plugin-react-swc@npm:3.6.0"