@faststore/core 4.9.3-dev.1 → 4.10.0-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,163 @@
1
+ import {
2
+ DecoratorNode,
3
+ type DOMExportOutput,
4
+ type EditorConfig,
5
+ type LexicalEditor,
6
+ type NodeKey,
7
+ type SerializedLexicalNode,
8
+ type Spread,
9
+ } from 'lexical'
10
+
11
+ export type ImageAlignment = 'left' | 'center' | 'right'
12
+
13
+ export type SerializedRichTextImageNode = Spread<
14
+ {
15
+ type: 'image'
16
+ version: 1
17
+ src: string
18
+ altText: string
19
+ alignment: ImageAlignment
20
+ widthPercent: number
21
+ },
22
+ SerializedLexicalNode
23
+ >
24
+
25
+ const VALID_ALIGNMENTS = new Set<ImageAlignment>(['left', 'center', 'right'])
26
+ const DEFAULT_ALIGNMENT: ImageAlignment = 'left'
27
+ const DEFAULT_WIDTH_PERCENT = 100
28
+ const RICH_TEXT_CONTENT_MAX_WIDTH = 720
29
+ const DEFAULT_IMAGE_QUALITY = 8
30
+
31
+ function resolveAlignment(alignment: unknown): ImageAlignment {
32
+ return VALID_ALIGNMENTS.has(alignment as ImageAlignment)
33
+ ? (alignment as ImageAlignment)
34
+ : DEFAULT_ALIGNMENT
35
+ }
36
+
37
+ function resolveWidthPercent(widthPercent: unknown): number {
38
+ if (typeof widthPercent !== 'number' || widthPercent <= 0) {
39
+ return DEFAULT_WIDTH_PERCENT
40
+ }
41
+
42
+ return Math.min(widthPercent, DEFAULT_WIDTH_PERCENT)
43
+ }
44
+
45
+ function withVtexResizeParams(src: string, widthPercent: number): string {
46
+ if (!src.includes('vtexassets') || !src.includes('/assets')) {
47
+ return src
48
+ }
49
+
50
+ try {
51
+ const url = new URL(src)
52
+ const targetWidth = Math.max(
53
+ 1,
54
+ Math.round((RICH_TEXT_CONTENT_MAX_WIDTH * widthPercent) / 100)
55
+ )
56
+
57
+ url.searchParams.set('width', targetWidth.toString())
58
+ url.searchParams.set('aspect', 'true')
59
+ url.searchParams.set('quality', DEFAULT_IMAGE_QUALITY.toString())
60
+
61
+ return url.toString()
62
+ } catch {
63
+ return src
64
+ }
65
+ }
66
+
67
+ export class RichTextImageNode extends DecoratorNode<null> {
68
+ __src: string
69
+ __altText: string
70
+ __alignment: ImageAlignment
71
+ __widthPercent: number
72
+
73
+ constructor(
74
+ src: string,
75
+ altText: string,
76
+ alignment: ImageAlignment = DEFAULT_ALIGNMENT,
77
+ widthPercent: number = DEFAULT_WIDTH_PERCENT,
78
+ key?: NodeKey
79
+ ) {
80
+ super(key)
81
+ this.__src = src
82
+ this.__altText = altText
83
+ this.__alignment = resolveAlignment(alignment)
84
+ this.__widthPercent = resolveWidthPercent(widthPercent)
85
+ }
86
+
87
+ static getType(): string {
88
+ return 'image'
89
+ }
90
+
91
+ static clone(node: RichTextImageNode): RichTextImageNode {
92
+ return new RichTextImageNode(
93
+ node.__src,
94
+ node.__altText,
95
+ node.__alignment,
96
+ node.__widthPercent,
97
+ node.__key
98
+ )
99
+ }
100
+
101
+ static importDOM(): null {
102
+ return null
103
+ }
104
+
105
+ static importJSON(
106
+ serializedNode: SerializedRichTextImageNode
107
+ ): RichTextImageNode {
108
+ return new RichTextImageNode(
109
+ typeof serializedNode.src === 'string' ? serializedNode.src : '',
110
+ typeof serializedNode.altText === 'string' ? serializedNode.altText : '',
111
+ serializedNode.alignment,
112
+ serializedNode.widthPercent
113
+ )
114
+ }
115
+
116
+ exportJSON(): SerializedRichTextImageNode {
117
+ return {
118
+ type: 'image',
119
+ version: 1,
120
+ src: this.__src,
121
+ altText: this.__altText,
122
+ alignment: this.__alignment,
123
+ widthPercent: this.__widthPercent,
124
+ }
125
+ }
126
+
127
+ isInline(): boolean {
128
+ return false
129
+ }
130
+
131
+ createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement {
132
+ return document.createElement('div')
133
+ }
134
+
135
+ updateDOM(): boolean {
136
+ return false
137
+ }
138
+
139
+ decorate(): null {
140
+ return null
141
+ }
142
+
143
+ exportDOM(_editor: LexicalEditor): DOMExportOutput {
144
+ if (!this.__src) {
145
+ return { element: null }
146
+ }
147
+
148
+ const wrapper = document.createElement('div')
149
+ wrapper.dataset.fsRichTextImageWrapper = ''
150
+ wrapper.dataset.fsRichTextImageAlignment = this.__alignment
151
+
152
+ const img = document.createElement('img')
153
+ img.dataset.fsRichTextImage = ''
154
+ img.src = withVtexResizeParams(this.__src, this.__widthPercent)
155
+ img.alt = this.__altText
156
+ img.setAttribute('loading', 'lazy')
157
+ img.style.width = `${this.__widthPercent}%`
158
+
159
+ wrapper.append(img)
160
+
161
+ return { element: wrapper }
162
+ }
163
+ }
@@ -11,6 +11,11 @@ import discoveryConfig from 'discovery.config'
11
11
  import { getJWTAutCookie } from 'src/utils/getCookie'
12
12
  import { getRequestHostname } from 'src/utils/getRequestHostname'
13
13
  import { isLocalHost } from 'src/utils/isLocalHost'
14
+ import {
15
+ isHostAllowed,
16
+ removeCookieDomain,
17
+ singleForwardedHost,
18
+ } from 'src/utils/trustedForwardedHost'
14
19
  import { shouldForceRefreshTokenForValidateSession } from 'src/utils/validateSessionRefreshToken'
15
20
  import { execute } from '../../server'
16
21
  import { logger } from '@faststore/diagnostics'
@@ -56,22 +61,10 @@ const shouldReplaceCookieDomain = ({
56
61
  }
57
62
 
58
63
  /**
59
- * Determines if host is eligible for domain normalization.
60
- */
61
- const isAllowedHost = ({
62
- host,
63
- allowList,
64
- }: {
65
- host: string
66
- allowList: string[]
67
- }) => {
68
- const normalizedHost = host.toLowerCase()
69
-
70
- return allowList.some((suffix) => normalizedHost.endsWith(suffix))
71
- }
72
-
73
- /**
74
- * Ensure the cookie domain matches the current host so the browser can store it.
64
+ * On allowlisted hosts (previews, localhost) the upstream cookie domain does not
65
+ * match the browser host, so the Domain attribute is dropped and the cookie
66
+ * becomes host-only. The host is never written into the cookie, so a forged
67
+ * x-forwarded-host cannot choose the cookie scope.
75
68
  */
76
69
  const normalizeSetCookieDomain = ({
77
70
  request,
@@ -85,20 +78,31 @@ const normalizeSetCookieDomain = ({
85
78
  return setCookie
86
79
  }
87
80
 
88
- const host = getRequestHostname(request.headers.host)
81
+ // The preview ingress forwards a client-supplied x-forwarded-host as is, so
82
+ // it only decides whether the Domain is dropped, never which domain is used.
83
+ const forwardedHeader = request.headers['x-forwarded-host']
84
+ const forwardedHost = getRequestHostname(singleForwardedHost(forwardedHeader))
85
+ if (forwardedHeader && !forwardedHost) {
86
+ // The value is client-controllable, so it is not logged.
87
+ OTELLogger('warn', 'Ignoring multi-value or malformed x-forwarded-host')
88
+ }
89
+ const host =
90
+ forwardedHost && isHostAllowed(forwardedHost, ALLOWED_HOST_SUFFIXES)
91
+ ? forwardedHost
92
+ : getRequestHostname(request.headers.host)
89
93
  if (!host) {
90
94
  return setCookie
91
95
  }
92
96
  const cookieDomain = domainMatch[1]
93
97
 
94
98
  if (
95
- !isAllowedHost({ host, allowList: ALLOWED_HOST_SUFFIXES }) ||
99
+ !isHostAllowed(host, ALLOWED_HOST_SUFFIXES) ||
96
100
  !shouldReplaceCookieDomain({ cookieDomain, host })
97
101
  ) {
98
102
  return setCookie
99
103
  }
100
104
 
101
- return setCookie.replace(MATCH_DOMAIN_REGEXP, `; domain=${host}`)
105
+ return removeCookieDomain(setCookie)
102
106
  }
103
107
 
104
108
  const parseRequest = (request: NextApiRequest) => {
@@ -0,0 +1,50 @@
1
+ // The result is written verbatim into a Set-Cookie Domain attribute, so
2
+ // characters such as ";" or spaces would inject cookie attributes.
3
+ const HOSTNAME_PATTERN = /^[a-z0-9.-]+$/
4
+
5
+ /**
6
+ * An entry starting with "." matches subdomains only; any other entry matches
7
+ * the host itself and its subdomains. Matching is anchored on a label boundary,
8
+ * so "evil-localhost" never matches "localhost".
9
+ */
10
+ export function isHostAllowed(host: string, allowList: string[]) {
11
+ const normalizedHost = host.toLowerCase()
12
+
13
+ if (!HOSTNAME_PATTERN.test(normalizedHost)) {
14
+ return false
15
+ }
16
+
17
+ return allowList.some((entry) =>
18
+ entry.startsWith('.')
19
+ ? normalizedHost.endsWith(entry)
20
+ : normalizedHost === entry || normalizedHost.endsWith(`.${entry}`)
21
+ )
22
+ }
23
+
24
+ /**
25
+ * Returns the `x-forwarded-host` value only when exactly one is present.
26
+ *
27
+ * Which hop of a proxy chain is trustworthy is not known here: with several
28
+ * values the leftmost one is whatever the client sent. So a multi-value header
29
+ * (comma-joined by Node, or an array) is ignored and the caller falls back to
30
+ * `host`. A single value still assumes the ingress overwrites the header.
31
+ */
32
+ export function singleForwardedHost(
33
+ header: string | string[] | undefined
34
+ ): string | undefined {
35
+ if (Array.isArray(header)) {
36
+ return header.length === 1 ? singleForwardedHost(header[0]) : undefined
37
+ }
38
+
39
+ const value = header?.trim()
40
+
41
+ return value && !value.includes(',') ? value : undefined
42
+ }
43
+
44
+ /**
45
+ * Drops every Domain attribute so the cookie becomes host-only: the browser
46
+ * scopes it to the exact host it requested, whatever header picked this path.
47
+ */
48
+ export function removeCookieDomain(setCookie: string): string {
49
+ return setCookie.replaceAll(/;\s*domain=[^;]*/gi, '')
50
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { render } from '@testing-library/react'
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ const isContentPlatformSourceMock = vi.hoisted(() => vi.fn(() => true))
9
+
10
+ vi.mock('src/server/content/utils', () => ({
11
+ isContentPlatformSource: isContentPlatformSourceMock,
12
+ }))
13
+
14
+ import { RichText } from '../../../../src/components/ui/RichText/RichText'
15
+
16
+ function textNode(text: string) {
17
+ return {
18
+ detail: 0,
19
+ format: 0,
20
+ mode: 'normal',
21
+ style: '',
22
+ text,
23
+ type: 'text',
24
+ version: 1,
25
+ }
26
+ }
27
+
28
+ function paragraphNode(children: unknown[]) {
29
+ return {
30
+ children,
31
+ direction: 'ltr',
32
+ format: '',
33
+ indent: 0,
34
+ type: 'paragraph',
35
+ version: 1,
36
+ }
37
+ }
38
+
39
+ function headingNode(text: string, tag = 'h2') {
40
+ return {
41
+ children: [textNode(text)],
42
+ direction: 'ltr',
43
+ format: '',
44
+ indent: 0,
45
+ type: 'heading',
46
+ version: 1,
47
+ tag,
48
+ }
49
+ }
50
+
51
+ function linkNode(url: string, text: string) {
52
+ return {
53
+ children: [textNode(text)],
54
+ direction: 'ltr',
55
+ format: '',
56
+ indent: 0,
57
+ type: 'link',
58
+ version: 1,
59
+ rel: null,
60
+ target: null,
61
+ title: null,
62
+ url,
63
+ }
64
+ }
65
+
66
+ function listNode(itemText: string) {
67
+ return {
68
+ children: [
69
+ {
70
+ children: [textNode(itemText)],
71
+ direction: 'ltr',
72
+ format: '',
73
+ indent: 0,
74
+ type: 'listitem',
75
+ version: 1,
76
+ value: 1,
77
+ },
78
+ ],
79
+ direction: 'ltr',
80
+ format: '',
81
+ indent: 0,
82
+ type: 'list',
83
+ version: 1,
84
+ listType: 'bullet',
85
+ start: 1,
86
+ tag: 'ul',
87
+ }
88
+ }
89
+
90
+ function imageNode(overrides: Record<string, unknown> = {}) {
91
+ return {
92
+ type: 'image',
93
+ version: 1,
94
+ src: 'https://storeaccount.vtexassets.com/assets/vtex.file-manager-graphql/images/abc123.png',
95
+ altText: 'A nice picture',
96
+ alignment: 'center',
97
+ widthPercent: 60,
98
+ ...overrides,
99
+ }
100
+ }
101
+
102
+ function lexicalContent(children: unknown[]) {
103
+ return JSON.stringify({
104
+ root: {
105
+ children,
106
+ direction: 'ltr',
107
+ format: '',
108
+ indent: 0,
109
+ type: 'root',
110
+ version: 1,
111
+ },
112
+ })
113
+ }
114
+
115
+ function draftJsContent(text: string) {
116
+ return JSON.stringify({
117
+ blocks: [
118
+ {
119
+ key: '1',
120
+ text,
121
+ type: 'unstyled',
122
+ depth: 0,
123
+ inlineStyleRanges: [],
124
+ entityRanges: [],
125
+ data: {},
126
+ },
127
+ ],
128
+ entityMap: {},
129
+ })
130
+ }
131
+
132
+ describe('RichText', () => {
133
+ beforeEach(() => {
134
+ isContentPlatformSourceMock.mockReturnValue(true)
135
+ })
136
+
137
+ it('renders an image node with the correct src, alt, alignment and width', () => {
138
+ const { container } = render(
139
+ <RichText content={lexicalContent([imageNode()])} />
140
+ )
141
+
142
+ const wrapper = container.querySelector('[data-fs-rich-text-image-wrapper]')
143
+ const img = container.querySelector<HTMLImageElement>(
144
+ '[data-fs-rich-text-image]'
145
+ )
146
+
147
+ expect(wrapper?.getAttribute('data-fs-rich-text-image-alignment')).toBe(
148
+ 'center'
149
+ )
150
+ expect(img?.getAttribute('alt')).toBe('A nice picture')
151
+ expect(img?.getAttribute('loading')).toBe('lazy')
152
+ expect(img?.getAttribute('style')).toBe('width: 60%;')
153
+
154
+ const src = img?.getAttribute('src') ?? ''
155
+ expect(src).toContain('width=432')
156
+ expect(src).toContain('aspect=true')
157
+ expect(src).toContain('quality=8')
158
+ })
159
+
160
+ it('renders images together with text, headings, links and lists in document order', () => {
161
+ const { container } = render(
162
+ <RichText
163
+ content={lexicalContent([
164
+ headingNode('Title'),
165
+ paragraphNode([
166
+ textNode('See '),
167
+ linkNode('https://example.com', 'this link'),
168
+ ]),
169
+ listNode('An item'),
170
+ imageNode(),
171
+ ])}
172
+ />
173
+ )
174
+
175
+ const html = container.innerHTML
176
+
177
+ expect(container.querySelector('h2')?.textContent).toBe('Title')
178
+ expect(
179
+ container.querySelector('a[href="https://example.com"]')
180
+ ).toBeTruthy()
181
+ expect(
182
+ container.querySelector('[data-fs-rich-text-list="true"]')
183
+ ).toBeTruthy()
184
+ expect(container.querySelector('[data-fs-rich-text-image]')).toBeTruthy()
185
+
186
+ const headingIndex = html.indexOf('<h2')
187
+ const linkIndex = html.indexOf('href="https://example.com"')
188
+ const listIndex = html.indexOf('data-fs-rich-text-list')
189
+ const imageIndex = html.indexOf('data-fs-rich-text-image-wrapper')
190
+
191
+ expect(headingIndex).toBeLessThan(linkIndex)
192
+ expect(linkIndex).toBeLessThan(listIndex)
193
+ expect(listIndex).toBeLessThan(imageIndex)
194
+ })
195
+
196
+ it('drops an image node with a missing src without breaking the rest of the content', () => {
197
+ const { container } = render(
198
+ <RichText
199
+ content={lexicalContent([
200
+ paragraphNode([textNode('Before')]),
201
+ imageNode({ src: '' }),
202
+ paragraphNode([textNode('After')]),
203
+ ])}
204
+ />
205
+ )
206
+
207
+ expect(container.querySelector('[data-fs-rich-text-image]')).toBeNull()
208
+ expect(
209
+ container.querySelector('[data-fs-rich-text-image-wrapper]')
210
+ ).toBeNull()
211
+ expect(container.textContent).toContain('Before')
212
+ expect(container.textContent).toContain('After')
213
+ })
214
+
215
+ it('does not throw and skips an image node with an unsupported/unexpected shape', () => {
216
+ expect(() =>
217
+ render(
218
+ <RichText
219
+ content={lexicalContent([
220
+ paragraphNode([textNode('Still here')]),
221
+ {
222
+ type: 'image',
223
+ version: 2,
224
+ imageUrl: 'https://storeaccount.vtexassets.com/assets/x.png',
225
+ },
226
+ ])}
227
+ />
228
+ )
229
+ ).not.toThrow()
230
+
231
+ const { container } = render(
232
+ <RichText
233
+ content={lexicalContent([
234
+ paragraphNode([textNode('Still here')]),
235
+ {
236
+ type: 'image',
237
+ version: 2,
238
+ imageUrl: 'https://storeaccount.vtexassets.com/assets/x.png',
239
+ },
240
+ ])}
241
+ />
242
+ )
243
+
244
+ expect(container.querySelector('[data-fs-rich-text-image]')).toBeNull()
245
+ expect(container.textContent).toContain('Still here')
246
+ })
247
+
248
+ it('renders multiple images, defaulting alignment/width when omitted and passing through non-VTEX URLs unmodified', () => {
249
+ const { container } = render(
250
+ <RichText
251
+ content={lexicalContent([
252
+ imageNode({ alignment: 'right', widthPercent: 30 }),
253
+ imageNode({
254
+ src: 'https://storeaccount.vtexassets.com/assets/vtex.file-manager-graphql/images/no-layout.png',
255
+ alignment: undefined,
256
+ widthPercent: undefined,
257
+ }),
258
+ imageNode({
259
+ src: 'https://cdn.example.com/external.png',
260
+ alignment: 'center',
261
+ widthPercent: 80,
262
+ }),
263
+ ])}
264
+ />
265
+ )
266
+
267
+ const wrappers = container.querySelectorAll(
268
+ '[data-fs-rich-text-image-wrapper]'
269
+ )
270
+ expect(wrappers).toHaveLength(3)
271
+
272
+ expect(wrappers[0].getAttribute('data-fs-rich-text-image-alignment')).toBe(
273
+ 'right'
274
+ )
275
+
276
+ expect(wrappers[1].getAttribute('data-fs-rich-text-image-alignment')).toBe(
277
+ 'left'
278
+ )
279
+ expect(
280
+ wrappers[1]
281
+ .querySelector('[data-fs-rich-text-image]')
282
+ ?.getAttribute('style')
283
+ ).toBe('width: 100%;')
284
+
285
+ const externalImg = wrappers[2].querySelector('[data-fs-rich-text-image]')
286
+ expect(externalImg?.getAttribute('src')).toBe(
287
+ 'https://cdn.example.com/external.png'
288
+ )
289
+ })
290
+
291
+ it('clamps widthPercent to 100 so the image never overflows its container', () => {
292
+ const { container } = render(
293
+ <RichText content={lexicalContent([imageNode({ widthPercent: 150 })])} />
294
+ )
295
+
296
+ const img = container.querySelector('[data-fs-rich-text-image]')
297
+ expect(img?.getAttribute('style')).toBe('width: 100%;')
298
+ })
299
+
300
+ it('keeps rendering existing hCMS Draft.js content unaffected', () => {
301
+ isContentPlatformSourceMock.mockReturnValue(false)
302
+
303
+ const { container } = render(
304
+ <RichText content={draftJsContent('Hello legacy content')} />
305
+ )
306
+
307
+ expect(container.textContent).toContain('Hello legacy content')
308
+ expect(container.querySelector('[data-fs-rich-text-image]')).toBeNull()
309
+ })
310
+
311
+ it('keeps rendering existing Lexical content without images unaffected', () => {
312
+ const { container } = render(
313
+ <RichText
314
+ content={lexicalContent([paragraphNode([textNode('No images here')])])}
315
+ />
316
+ )
317
+
318
+ expect(container.textContent).toContain('No images here')
319
+ expect(container.querySelector('[data-fs-rich-text-image]')).toBeNull()
320
+ })
321
+ })