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"