From dc47d59475d12930a3b3f4d2e18a7cedf9926e13 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Sat, 15 Aug 2026 10:21:00 +0200 Subject: [PATCH 01/19] test(canvas): cover store routing for the source url, the doc check and the collab room --- test/fixtures/nx/utils/utils.js | 2 + .../canvas/ew-editor-doc/prose-room.test.js | 49 +++++++ .../canvas/ew-editor-doc/utils/ctx.test.js | 59 ++++++++ .../canvas/ew-editor-doc/utils/source.test.js | 126 ++++++++++++++++++ 4 files changed, 236 insertions(+) create mode 100644 test/unit/blocks/canvas/ew-editor-doc/prose-room.test.js create mode 100644 test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js create mode 100644 test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js diff --git a/test/fixtures/nx/utils/utils.js b/test/fixtures/nx/utils/utils.js index b771487b9..b084967ed 100644 --- a/test/fixtures/nx/utils/utils.js +++ b/test/fixtures/nx/utils/utils.js @@ -8,6 +8,8 @@ export const loadScript = async () => {}; export const DA_ADMIN = 'https://admin.da.live'; +export const DA_COLLAB = 'wss://collab.da.live'; + let _hashState = {}; const _hashSubscribers = new Set(); export const hashChange = { diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-room.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-room.test.js new file mode 100644 index 000000000..6a02d703f --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-room.test.js @@ -0,0 +1,49 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let initProse; + +before(async () => { + ({ default: initProse } = await import('../../../../../blocks/canvas/ew-editor-doc/prose.js')); +}); + +async function connect(sourceUrl) { + const result = await initProse({ + path: sourceUrl, + permissions: ['read', 'write'], + setEditable: () => {}, + getToken: () => 'test-token', + }); + return result; +} + +function teardown({ wsProvider, ydoc, view }) { + wsProvider.disconnect({ data: 'Client navigation' }); + wsProvider.destroy?.(); + view?.destroy?.(); + ydoc.destroy(); +} + +describe('canvas collab room', () => { + it('keeps a legacy document in its da-admin room', async () => { + const result = await connect('https://admin.da.live/source/roomorg/roomsite/page.html'); + try { + expect(result.wsProvider.roomname).to.equal('https://admin.da.live/source/roomorg/roomsite/page.html'); + } finally { + teardown(result); + } + }); + + it('puts a source-bus document in its api.aem.live room', async () => { + // da-collab reads the store off the room name, so a da-admin room for a + // source-bus document edits the copy the site does not serve. + const result = await connect('https://api.aem.live/roomorg/sites/roomsite/source/page.html'); + try { + expect(result.wsProvider.roomname).to.equal('https://api.aem.live/roomorg/sites/roomsite/source/page.html'); + } finally { + teardown(result); + } + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js b/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js new file mode 100644 index 000000000..42557e54f --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js @@ -0,0 +1,59 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let sourceUrlFromEditorCtx; +let editorDocCanLoad; + +before(async () => { + ({ sourceUrlFromEditorCtx, editorDocCanLoad } = await import('../../../../../../blocks/canvas/ew-editor-doc/utils/ctx.js')); +}); + +function stubPing({ upgraded }) { + const saved = window.fetch; + window.fetch = async () => new Response('', { + status: 200, + headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {}, + }); + return () => { window.fetch = saved; }; +} + +afterEach(() => { + window.localStorage.removeItem('hlx6-upgrade'); +}); + +describe('sourceUrlFromEditorCtx', () => { + it('routes a source-bus document to api.aem.live', async () => { + const restore = stubPing({ upgraded: true }); + try { + const url = await sourceUrlFromEditorCtx({ org: 'ctxorg', repo: 'ctxsite', path: '/ctxorg/ctxsite/page' }); + expect(url).to.equal('https://api.aem.live/ctxorg/sites/ctxsite/source/page.html'); + } finally { + restore(); + } + }); + + it('answers null for a ctx with no path', async () => { + expect(await sourceUrlFromEditorCtx({ org: 'o', repo: 's' })).to.equal(null); + expect(await sourceUrlFromEditorCtx(null)).to.equal(null); + }); +}); + +describe('editorDocCanLoad', () => { + it('is decided without a network call', () => { + // A promise is truthy, so this has to stay synchronous or an empty path reads as loadable. + expect(editorDocCanLoad({ org: 'o', repo: 's', path: '/o/s/page' })).to.equal(true); + }); + + it('refuses a ctx with an empty path', () => { + expect(editorDocCanLoad({ org: 'o', repo: 's', path: '' })).to.equal(false); + expect(editorDocCanLoad({ org: 'o', repo: 's', path: ' ' })).to.equal(false); + }); + + it('refuses a ctx missing org or site', () => { + expect(editorDocCanLoad({ repo: 's', path: '/o/s/page' })).to.equal(false); + expect(editorDocCanLoad({ org: 'o', path: '/o/s/page' })).to.equal(false); + expect(editorDocCanLoad(null)).to.equal(false); + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js b/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js new file mode 100644 index 000000000..cbb00302b --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js @@ -0,0 +1,126 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let buildSourceUrl; +let checkDoc; + +before(async () => { + ({ buildSourceUrl, checkDoc } = await import('../../../../../../blocks/canvas/ew-editor-doc/utils/source.js')); +}); + +// isHlx6 pings admin.hlx.page and reads the upgrade header off the response, so a +// stubbed fetch decides which store a site is on. Each case needs its own org/site +// because the answer is memoized per site for the life of the page. +function stubPing({ upgraded }) { + const saved = window.fetch; + const calls = []; + window.fetch = async (url, opts) => { + calls.push({ url: typeof url === 'string' ? url : url.url, opts }); + const headers = upgraded ? { 'x-api-upgrade-available': 'true' } : {}; + return new Response('', { status: 200, headers }); + }; + return { calls, restore: () => { window.fetch = saved; } }; +} + +afterEach(() => { + window.localStorage.removeItem('hlx6-upgrade'); +}); + +describe('canvas buildSourceUrl', () => { + it('keeps a legacy site on da-admin', async () => { + const { restore } = stubPing({ upgraded: false }); + try { + const url = await buildSourceUrl('/legacyorg/legacysite/dir/page'); + expect(url).to.equal('https://admin.da.live/source/legacyorg/legacysite/dir/page.html'); + } finally { + restore(); + } + }); + + it('sends a source-bus site to api.aem.live', async () => { + const { restore } = stubPing({ upgraded: true }); + try { + const url = await buildSourceUrl('/hlxorg/hlxsite/dir/page'); + expect(url).to.equal('https://api.aem.live/hlxorg/sites/hlxsite/source/dir/page.html'); + } finally { + restore(); + } + }); + + it('routes a document at the site root', async () => { + const { restore } = stubPing({ upgraded: true }); + try { + const url = await buildSourceUrl('/rootorg/rootsite/index'); + expect(url).to.equal('https://api.aem.live/rootorg/sites/rootsite/source/index.html'); + } finally { + restore(); + } + }); + + it('answers null for a path it cannot use', async () => { + expect(await buildSourceUrl('')).to.equal(null); + expect(await buildSourceUrl(' ')).to.equal(null); + expect(await buildSourceUrl(null)).to.equal(null); + expect(await buildSourceUrl(42)).to.equal(null); + }); +}); + +describe('canvas checkDoc', () => { + it('asks api.aem.live for a source-bus document, with a bearer', async () => { + const { calls, restore } = stubPing({ upgraded: true }); + try { + const url = await buildSourceUrl('/checkorg/checksite/page'); + await checkDoc(url); + const head = calls.find((c) => c.opts?.method === 'HEAD'); + expect(head, 'no HEAD was issued').to.exist; + expect(head.url).to.equal('https://api.aem.live/checkorg/sites/checksite/source/page.html'); + expect(head.opts.headers.Authorization).to.equal('Bearer test-token'); + } finally { + restore(); + } + }); + + it('asks da-admin for a legacy document, with a bearer', async () => { + const { calls, restore } = stubPing({ upgraded: false }); + try { + const url = await buildSourceUrl('/checkleg/checkleg/page'); + await checkDoc(url); + const head = calls.find((c) => c.opts?.method === 'HEAD'); + expect(head, 'no HEAD was issued').to.exist; + expect(head.url).to.equal('https://admin.da.live/source/checkleg/checkleg/page.html'); + expect(head.opts.headers.Authorization).to.equal('Bearer test-token'); + } finally { + restore(); + } + }); + + it('reads the action list da-admin sends', async () => { + const saved = window.fetch; + window.fetch = async (url) => { + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); + return new Response('', { + status: 200, + headers: { 'x-da-actions': '/site/page.html=read' }, + }); + }; + try { + const resp = await checkDoc('https://admin.da.live/source/permorg/permsite/page.html'); + expect(resp.permissions).to.deep.equal(['read']); + } finally { + window.fetch = saved; + } + }); + + it('assumes read and write when the store sends no action list', async () => { + const { restore } = stubPing({ upgraded: true }); + try { + const url = await buildSourceUrl('/faketorg/fakesite/page'); + const resp = await checkDoc(url); + expect(resp.permissions).to.deep.equal(['read', 'write']); + } finally { + restore(); + } + }); +}); From 95fa1fb73b41934bc1d8e0b3dff7fa8ae7e430ce Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Sat, 15 Aug 2026 10:47:00 +0200 Subject: [PATCH 02/19] fix(canvas): route the document and the collab room to the site's store --- blocks/canvas/canvas.js | 2 +- blocks/canvas/ew-editor-doc/ew-editor-doc.js | 2 +- blocks/canvas/ew-editor-doc/prose.js | 5 ++-- blocks/canvas/ew-editor-doc/utils/ctx.js | 5 ++-- blocks/canvas/ew-editor-doc/utils/source.js | 26 +++++++++++--------- 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/blocks/canvas/canvas.js b/blocks/canvas/canvas.js index 257cd1711..4c0fa071c 100644 --- a/blocks/canvas/canvas.js +++ b/blocks/canvas/canvas.js @@ -108,7 +108,7 @@ async function syncCanvasEditorsToHash({ mountRoot, header, state }) { return; } const ctx = editorCtxFromHashState(state, fullPath); - const session = await resolveEditorDocSession(sourceUrlFromEditorCtx(ctx)); + const session = await resolveEditorDocSession(await sourceUrlFromEditorCtx(ctx)); if (loadCount !== editorLoadCount) return; if (!session.ok) { removeCanvasEditors(mountRoot); diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js index 54da2e2ce..aff71cdbd 100644 --- a/blocks/canvas/ew-editor-doc/ew-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js @@ -265,7 +265,7 @@ export class EwEditorDoc extends LitElement { return; } - const sourceUrl = sourceUrlFromEditorCtx(this.ctx); + const sourceUrl = await sourceUrlFromEditorCtx(this.ctx); const session = this.session ?? await resolveEditorDocSession(sourceUrl); if (!session.ok) { diff --git a/blocks/canvas/ew-editor-doc/prose.js b/blocks/canvas/ew-editor-doc/prose.js index b99e01d07..4a3cdfc63 100644 --- a/blocks/canvas/ew-editor-doc/prose.js +++ b/blocks/canvas/ew-editor-doc/prose.js @@ -43,7 +43,7 @@ import { generateColor, getCollabIdentity } from './utils/collab.js'; import { checkBlockLibraryConfigured } from '../editor-utils/block-slash.js'; import { canvasBus } from '../utils/canvas-bus.js'; -const { DA_ADMIN, DA_COLLAB, hashChange } = await import(`${getNx()}/utils/utils.js`); +const { DA_COLLAB, hashChange } = await import(`${getNx()}/utils/utils.js`); function registerErrorHandler(ydoc) { ydoc.on('update', () => { @@ -94,7 +94,8 @@ export default async function initProse({ const ydoc = new Y.Doc(); const server = DA_COLLAB; - const roomName = `${DA_ADMIN}${new URL(path).pathname}`; + // da-collab reads the store off the room name, and `path` is already the store's source url. + const roomName = path; const wsOpts = { protocols: ['yjs'] }; let lastSentToken = null; diff --git a/blocks/canvas/ew-editor-doc/utils/ctx.js b/blocks/canvas/ew-editor-doc/utils/ctx.js index 975da88e4..6901a3416 100644 --- a/blocks/canvas/ew-editor-doc/utils/ctx.js +++ b/blocks/canvas/ew-editor-doc/utils/ctx.js @@ -1,4 +1,4 @@ -import { buildSourceUrl } from './source.js'; +import { buildSourceUrl, normalizeSourcePath } from './source.js'; export function sourceUrlFromEditorCtx(ctx) { return buildSourceUrl(ctx?.path); @@ -9,8 +9,9 @@ export function editorCtxHasOrgRepoPath(ctx) { return Boolean(org && repo && path); } +// Stays synchronous: the render phase needs an answer without waiting on the store lookup. export function editorDocCanLoad(ctx) { - return editorCtxHasOrgRepoPath(ctx) && Boolean(sourceUrlFromEditorCtx(ctx)); + return editorCtxHasOrgRepoPath(ctx) && Boolean(normalizeSourcePath(ctx.path)); } export function controllerPathnameFromEditorCtx(ctx) { diff --git a/blocks/canvas/ew-editor-doc/utils/source.js b/blocks/canvas/ew-editor-doc/utils/source.js index b22ccec4b..e000d0684 100644 --- a/blocks/canvas/ew-editor-doc/utils/source.js +++ b/blocks/canvas/ew-editor-doc/utils/source.js @@ -1,23 +1,25 @@ -import { getNx } from '../../../../scripts/utils.js'; -import { daFetch } from '../../../shared/utils.js'; +import { getNx, getNx2Api } from '../../../../scripts/utils.js'; const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`); -export function buildSourceUrl(path) { +export function normalizeSourcePath(path) { if (!path || typeof path !== 'string') return null; const trimmed = path.replace(/^\//, '').trim(); - if (!trimmed) return null; - return `${DA_ADMIN}/source/${trimmed}.html`; + return trimmed || null; } -export function parsePermissions(resp) { - const hint = resp.headers.get('x-da-child-actions') ?? resp.headers.get('x-da-actions'); - if (hint) resp.permissions = hint.split('=').pop().split(','); - else resp.permissions = ['read', 'write']; - return resp; +export async function buildSourceUrl(path) { + const trimmed = normalizeSourcePath(path); + if (!trimmed) return null; + const [org, site, ...parts] = trimmed.split('/'); + const { AEM_API, isHlx6 } = await getNx2Api(); + if (org && site && parts.length && await isHlx6(org, site)) { + return `${AEM_API}/${org}/sites/${site}/source/${parts.join('/')}.html`; + } + return `${DA_ADMIN}/source/${trimmed}.html`; } export async function checkDoc(sourceUrl) { - const resp = await daFetch(sourceUrl, { method: 'HEAD' }); - return parsePermissions(resp); + const { daFetch } = await getNx2Api(); + return daFetch({ url: sourceUrl, opts: { method: 'HEAD' } }); } From 6536d1587d9aa24949f9be0bd056f7ec5f6168ef Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Sat, 15 Aug 2026 11:24:00 +0200 Subject: [PATCH 03/19] test(canvas): a store failure should not be reported as a permission denial --- .../load-editor-doc-status.test.js | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 test/unit/blocks/canvas/ew-editor-doc/load-editor-doc-status.test.js diff --git a/test/unit/blocks/canvas/ew-editor-doc/load-editor-doc-status.test.js b/test/unit/blocks/canvas/ew-editor-doc/load-editor-doc-status.test.js new file mode 100644 index 000000000..000ac9235 --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/load-editor-doc-status.test.js @@ -0,0 +1,67 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let sessionErrorFromResponse; + +before(async () => { + ({ sessionErrorFromResponse } = await import('../../../../../blocks/canvas/ew-editor-doc/utils/load-editor-doc.js')); +}); + +const respond = (status, { headers = {}, ok } = {}) => ({ + status, + ok: ok ?? (status >= 200 && status < 300), + headers: new Headers(headers), +}); + +describe('sessionErrorFromResponse', () => { + it('lets a document that loaded through', () => { + expect(sessionErrorFromResponse(respond(200))).to.equal(null); + }); + + it('lets a missing document through, so it can be created', () => { + expect(sessionErrorFromResponse(respond(404))).to.equal(null); + }); + + it('asks for a sign-in on 401', () => { + expect(sessionErrorFromResponse(respond(401))).to.deep.equal({ + ok: false, + error: 'Sign in required', + }); + }); + + it('reports a refusal on 403', () => { + expect(sessionErrorFromResponse(respond(403))).to.deep.equal({ + ok: false, + error: 'Not permitted', + }); + }); + + it('names the status instead of blaming permissions on 503', () => { + expect(sessionErrorFromResponse(respond(503))).to.deep.equal({ + ok: false, + error: 'Could not load the document (503)', + }); + }); + + it('adds the reason the store gave', () => { + const resp = respond(500, { headers: { 'x-error': 'source lookup failed' } }); + expect(sessionErrorFromResponse(resp)).to.deep.equal({ + ok: false, + error: 'Could not load the document (500): source lookup failed', + }); + }); + + it('says so when the store was never reached', () => { + // nx2 daFetch answers {} when it has no token to send, which carries no status. + expect(sessionErrorFromResponse({})).to.deep.equal({ + ok: false, + error: 'Could not reach the content store', + }); + expect(sessionErrorFromResponse(null)).to.deep.equal({ + ok: false, + error: 'Could not reach the content store', + }); + }); +}); From cf09f9686a5fb77265e6f8a0eee710de1d762f68 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Sat, 15 Aug 2026 11:39:00 +0200 Subject: [PATCH 04/19] fix(canvas): report the store's status instead of a blanket not-permitted --- .../ew-editor-doc/utils/load-editor-doc.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js index 19ade38ca..31db5afe2 100644 --- a/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js @@ -1,6 +1,17 @@ import { checkDoc } from './source.js'; import { initIms } from '../../../shared/utils.js'; +export function sessionErrorFromResponse(resp) { + const status = resp?.status; + if (typeof status !== 'number') return { ok: false, error: 'Could not reach the content store' }; + if (resp.ok || status === 404) return null; + if (status === 401) return { ok: false, error: 'Sign in required' }; + if (status === 403) return { ok: false, error: 'Not permitted' }; + const detail = resp.headers?.get?.('x-error'); + const reason = detail ? `: ${detail}` : ''; + return { ok: false, error: `Could not load the document (${status})${reason}` }; +} + export async function resolveEditorDocSession(sourceUrl) { const ims = await initIms(); const token = ims?.accessToken?.token ?? null; @@ -9,10 +20,8 @@ export async function resolveEditorDocSession(sourceUrl) { } const resp = await checkDoc(sourceUrl); - if (!resp.ok && resp.status !== 404) { - const error = resp.status === 401 ? 'Sign in required' : 'Not permitted'; - return { ok: false, error }; - } + const failure = sessionErrorFromResponse(resp); + if (failure) return failure; const permissions = resp.permissions || ['read']; return { ok: true, token, permissions }; From a292029ed3ee9e782d2b11e50e8561ff3559a694 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Sat, 15 Aug 2026 12:16:00 +0200 Subject: [PATCH 05/19] test(canvas): media bus images render from the preview origin --- .../prose-plugins/media-bus-image.test.js | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 test/unit/blocks/canvas/ew-editor-doc/prose-plugins/media-bus-image.test.js diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/media-bus-image.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/media-bus-image.test.js new file mode 100644 index 000000000..5de4b0f90 --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/media-bus-image.test.js @@ -0,0 +1,72 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../../scripts/utils.js'; +import { createTestEditor, destroyEditor } from '../../../edit/prose/test-helpers.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let getRenderableSrc; +let mediaBusImage; + +const ctx = { org: 'org', repo: 'repo', path: '/org/repo/page' }; +const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); }); + +before(async () => { + ({ default: mediaBusImage, getRenderableSrc } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js')); +}); + +describe('canvas getRenderableSrc', () => { + it('rewrites a relative media src to the preview origin', () => { + expect(getRenderableSrc('./media_123.png', ctx)).to.equal( + 'https://main--repo--org.preview.da.live/media_123.png', + ); + }); + + it('answers null for a src that is not on the media bus', () => { + expect(getRenderableSrc('https://example.com/foo.png', ctx)).to.equal(null); + expect(getRenderableSrc('/media_123.png', ctx)).to.equal(null); + expect(getRenderableSrc(null, ctx)).to.equal(null); + }); + + it('answers null when the ctx names no site', () => { + expect(getRenderableSrc('./media_123.png', { org: 'org' })).to.equal(null); + expect(getRenderableSrc('./media_123.png', null)).to.equal(null); + }); +}); + +describe('canvas mediaBusImage plugin', () => { + let editor; + + beforeEach(async () => { + editor = await createTestEditor({ additionalPlugins: [mediaBusImage(ctx)] }); + await nextFrame(); + }); + + afterEach(() => { + destroyEditor(editor); + }); + + it('renders a media bus image from the preview origin, and stores the relative src', async () => { + const { schema } = editor.view.state; + const image = schema.nodes.image.create({ src: './media_123.png' }); + editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(image)); + await nextFrame(); + + const img = editor.view.dom.querySelector('img'); + expect(img.src).to.equal('https://main--repo--org.preview.da.live/media_123.png'); + + let storedSrc; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'image') storedSrc = node.attrs.src; + }); + expect(storedSrc).to.equal('./media_123.png'); + }); + + it('leaves an absolute image src alone', async () => { + const { schema } = editor.view.state; + const image = schema.nodes.image.create({ src: 'https://example.com/pic.png' }); + editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(image)); + await nextFrame(); + + expect(editor.view.dom.querySelector('img').src).to.equal('https://example.com/pic.png'); + }); +}); From 7b68d9190270365d3e26732d3880ac81dc54e566 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Sat, 15 Aug 2026 12:34:00 +0200 Subject: [PATCH 06/19] fix(canvas): render media bus images from the preview origin --- blocks/canvas/ew-editor-doc/ew-editor-doc.js | 2 + .../prose-plugins/mediaBusImage.js | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js index aff71cdbd..8cf9ba9d4 100644 --- a/blocks/canvas/ew-editor-doc/ew-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js @@ -24,6 +24,7 @@ import { afterNextPaint, ensureProseMountedInShadow } from './utils/shadow-mount import { teardownEditorDocResources } from './utils/teardown.js'; import { hideSelectionToolbar, setSelectionToolbarCtx } from '../editor-utils/selection-toolbar.js'; import { createExtensionsBridgePlugin } from '../editor-utils/extensions-bridge.js'; +import mediaBusImage from './prose-plugins/mediaBusImage.js'; import { MESSAGE_TYPES } from '../utils/quick-edit-messages.js'; import { canvasBus } from '../utils/canvas-bus.js'; @@ -282,6 +283,7 @@ export class EwEditorDoc extends LitElement { setEditable: (editable) => this._setEditable(editable), getToken: () => token, extraPlugins: [ + mediaBusImage(this.ctx), createExtensionsBridgePlugin(), createTrackingPlugin( () => { diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js b/blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js new file mode 100644 index 000000000..0f030a498 --- /dev/null +++ b/blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js @@ -0,0 +1,37 @@ +// eslint-disable-next-line import/no-unresolved +import { Plugin, PluginKey } from 'da-y-wrapper'; +import { getLivePreviewUrl } from '../../../shared/constants.js'; + +const mediaBusImageKey = new PluginKey('canvasMediaBusImage'); + +// A document on the media bus stores an image src relative to the published page +// ("./media_123.png"), which only resolves where the page is served from. The canvas runs on +// another origin, so the src is rewritten to the doc's preview origin for display. The node attrs +// keep the relative path, and so does the saved document. +export function getRenderableSrc(src, ctx) { + if (!src || !src.startsWith('./media_')) return null; + const { org, repo } = ctx ?? {}; + if (!org || !repo) return null; + return `${getLivePreviewUrl(org, repo)}/${src.slice(2)}`; +} + +function updateImageSrcs(view, ctx) { + view.dom.querySelectorAll('img[src^="./media_"]').forEach((img) => { + const renderableSrc = getRenderableSrc(img.getAttribute('src'), ctx); + if (renderableSrc) img.src = renderableSrc; + }); +} + +export default function mediaBusImage(ctx) { + return new Plugin({ + key: mediaBusImageKey, + view(view) { + updateImageSrcs(view, ctx); + return { + update(updatedView, prevState) { + if (updatedView.state.doc !== prevState.doc) updateImageSrcs(updatedView, ctx); + }, + }; + }, + }); +} From 79d4c041835b54d3d06e4af0918ac229b0487fe7 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Sat, 15 Aug 2026 13:58:00 +0200 Subject: [PATCH 07/19] test(canvas): image uploads go to the site's store, in the doc pane and the wysiwyg --- .../prose-plugins/upload.test.js | 137 ++++++++++++++++++ .../ew-editor-wysiwyg/image-upload.test.js | 123 ++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js create mode 100644 test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js new file mode 100644 index 000000000..82556c7c2 --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js @@ -0,0 +1,137 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../../scripts/utils.js'; +import { createTestEditor, destroyEditor } from '../../../edit/prose/test-helpers.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let getSourceUploadContext; +let uploadImageFile; + +const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); }); + +before(async () => { + ({ getSourceUploadContext } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js')); + ({ uploadImageFile } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js')); +}); + +// isHlx6 pings admin.hlx.page, and the upload goes to whichever store that names. Each case needs +// its own org/site because the answer is memoized per site. +function stubStore({ upgraded, contentUrl = 'https://content.da.live/org/site/.doc/pic.png' }) { + const saved = window.fetch; + const calls = []; + window.fetch = async (url, opts) => { + const href = typeof url === 'string' ? url : url.url; + calls.push({ url: href, opts }); + if (href.includes('/ping/')) { + return new Response('', { + status: 200, + headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {}, + }); + } + return new Response(JSON.stringify({ source: { contentUrl } }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); + }; + return { calls, restore: () => { window.fetch = saved; } }; +} + +afterEach(() => { + window.localStorage.removeItem('hlx6-upgrade'); +}); + +describe('getSourceUploadContext', () => { + it('reads a da-admin source url', () => { + const ctx = getSourceUploadContext('https://admin.da.live/source/org/site/dir/doc.html'); + expect(ctx).to.deep.equal({ parent: '/org/site/dir', name: 'doc' }); + }); + + it('reads a source-bus source url', () => { + const ctx = getSourceUploadContext('https://api.aem.live/org/sites/site/source/dir/doc.html'); + expect(ctx).to.deep.equal({ parent: '/org/site/dir', name: 'doc' }); + }); + + it('reads a document at the site root', () => { + expect(getSourceUploadContext('https://admin.da.live/source/org/site/index.html')) + .to.deep.equal({ parent: '/org/site', name: 'index' }); + expect(getSourceUploadContext('https://api.aem.live/org/sites/site/source/index.html')) + .to.deep.equal({ parent: '/org/site', name: 'index' }); + }); + + it('answers null for a url it cannot read', () => { + expect(getSourceUploadContext('')).to.equal(null); + expect(getSourceUploadContext('https://admin.da.live/list/org/site')).to.equal(null); + expect(getSourceUploadContext(null)).to.equal(null); + }); +}); + +describe('uploadImageFile', () => { + let editor; + + beforeEach(async () => { + editor = await createTestEditor(); + await nextFrame(); + }); + + afterEach(() => { + destroyEditor(editor); + }); + + const png = () => new File([new Uint8Array([1, 2, 3])], 'pic.png', { type: 'image/png' }); + + it('uploads to the source bus for a migrated site', async () => { + const { calls, restore } = stubStore({ upgraded: true, contentUrl: './media_abc.png' }); + try { + await uploadImageFile(editor.view, png(), { parent: '/upsorg/upssite/dir', name: 'doc' }); + + const upload = calls.find((c) => c.opts?.method === 'POST'); + expect(upload, 'nothing was uploaded').to.exist; + expect(upload.url).to.equal('https://api.aem.live/upsorg/sites/upssite/source/dir/.doc/pic.png'); + } finally { + restore(); + } + }); + + it('uploads to da-admin for a legacy site', async () => { + const { calls, restore } = stubStore({ upgraded: false }); + try { + await uploadImageFile(editor.view, png(), { parent: '/legorg/legsite/dir', name: 'doc' }); + + const upload = calls.find((c) => c.opts?.method === 'POST'); + expect(upload, 'nothing was uploaded').to.exist; + expect(upload.url).to.equal('https://admin.da.live/source/legorg/legsite/dir/.doc/pic.png'); + } finally { + restore(); + } + }); + + it('shows a media bus image without waiting for it to load', async () => { + // a relative src cannot load from the canvas origin, so waiting on it would leave the + // placeholder in the document for good + const { restore } = stubStore({ upgraded: true, contentUrl: './media_abc.png' }); + try { + await uploadImageFile(editor.view, png(), { parent: '/relorg/relsite', name: 'doc' }); + await nextFrame(); + + let stored; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'image') stored = node.attrs.src; + }); + expect(stored).to.equal('./media_abc.png'); + } finally { + restore(); + } + }); + + it('refuses a file type the store does not take', async () => { + const { calls, restore } = stubStore({ upgraded: false }); + try { + const pdf = new File([new Uint8Array([1])], 'doc.pdf', { type: 'application/pdf' }); + await uploadImageFile(editor.view, pdf, { parent: '/pdforg/pdfsite', name: 'doc' }); + + expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(0); + } finally { + restore(); + } + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js b/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js new file mode 100644 index 000000000..5258888b6 --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js @@ -0,0 +1,123 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../scripts/utils.js'; +import { createTestEditor, destroyEditor } from '../../edit/prose/test-helpers.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let handleImageReplace; + +const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); }); + +before(async () => { + ({ handleImageReplace } = await import('../../../../../blocks/canvas/ew-editor-wysiwyg/utils/image.js')); +}); + +function stubStore({ upgraded, contentUrl = './media_abc.png' }) { + const saved = window.fetch; + const calls = []; + window.fetch = async (url, opts) => { + const href = typeof url === 'string' ? url : url.url; + calls.push({ url: href, opts }); + if (href.includes('/ping/')) { + return new Response('', { + status: 200, + headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {}, + }); + } + return new Response(JSON.stringify({ source: { contentUrl } }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); + }; + return { calls, restore: () => { window.fetch = saved; } }; +} + +afterEach(() => { + window.localStorage.removeItem('hlx6-upgrade'); +}); + +describe('handleImageReplace', () => { + let editor; + + const ctxFor = (owner, repo) => { + const posted = []; + return { + posted, + ctx: { + owner, + repo, + path: '/page', + view: editor.view, + port: { postMessage: (message) => posted.push(message) }, + getToken: () => 'test-token', + }, + }; + }; + + const imageData = 'data:image/png;base64,iVBORw0KGgo='; + + beforeEach(async () => { + editor = await createTestEditor(); + await nextFrame(); + }); + + afterEach(() => { + destroyEditor(editor); + }); + + it('uploads to the source bus for a migrated site', async () => { + const { calls, restore } = stubStore({ upgraded: true }); + const { ctx } = ctxFor('wysorg', 'wyssite'); + try { + await handleImageReplace({ imageData, fileName: 'pic.png', originalSrc: '/old.png' }, ctx); + + const upload = calls.find((c) => c.opts?.method === 'POST'); + expect(upload, 'nothing was uploaded').to.exist; + expect(upload.url).to.equal('https://api.aem.live/wysorg/sites/wyssite/source/.page/pic.png'); + } finally { + restore(); + } + }); + + it('uploads to da-admin for a legacy site', async () => { + const { calls, restore } = stubStore({ upgraded: false, contentUrl: 'https://content.da.live/wyslegacy/wyslegacy/.page/pic.png' }); + const { ctx } = ctxFor('wyslegacy', 'wyslegacy'); + try { + await handleImageReplace({ imageData, fileName: 'pic.png', originalSrc: '/old.png' }, ctx); + + const upload = calls.find((c) => c.opts?.method === 'POST'); + expect(upload, 'nothing was uploaded').to.exist; + expect(upload.url).to.equal('https://admin.da.live/source/wyslegacy/wyslegacy/.page/pic.png'); + } finally { + restore(); + } + }); + + it('reports the src the store gave back, not one it composed', async () => { + const { restore } = stubStore({ upgraded: true, contentUrl: './media_xyz.png' }); + const { ctx, posted } = ctxFor('wysrep', 'wysrep'); + try { + await handleImageReplace({ imageData, fileName: 'pic.png', originalSrc: '/old.png' }, ctx); + + expect(posted.at(-1).payload.newSrc).to.equal('./media_xyz.png'); + } finally { + restore(); + } + }); + + it('reports a refused upload', async () => { + const saved = window.fetch; + window.fetch = async (url) => { + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); + return new Response('', { status: 403 }); + }; + const { ctx, posted } = ctxFor('wysref', 'wysref'); + try { + await handleImageReplace({ imageData, fileName: 'pic.png', originalSrc: '/old.png' }, ctx); + + expect(posted.at(-1).payload.error).to.contain('403'); + } finally { + window.fetch = saved; + } + }); +}); From b9ec78d71e0d39d44fcae0310d57cfbbe4bf96cb Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Sat, 15 Aug 2026 14:31:00 +0200 Subject: [PATCH 08/19] fix(canvas): upload images through the source api, into the site's store --- .../prose-plugins/base64Uploader.js | 56 +++++++++---------- .../ew-editor-doc/prose-plugins/imageDrop.js | 31 ++++++---- .../prose-plugins/sourceUploadContext.js | 35 ++++++------ .../canvas/ew-editor-wysiwyg/utils/image.js | 30 +++------- 4 files changed, 71 insertions(+), 81 deletions(-) diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js index c21820ed8..69fa37218 100644 --- a/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js +++ b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js @@ -1,10 +1,7 @@ import { Plugin } from 'da-y-wrapper'; -import { getNx } from '../../../../scripts/utils.js'; -import { daFetch } from '../../../shared/utils.js'; +import { getNx2Api } from '../../../../scripts/utils.js'; import { getSourceUploadContext } from './sourceUploadContext.js'; -const { DA_ADMIN, DA_CONTENT } = await import(`${getNx()}/utils/utils.js`); - const FPO_IMG_URL = '/blocks/edit/img/fpo.svg'; function makeHash(string) { @@ -14,6 +11,29 @@ function makeHash(string) { ), 0)); } +// The media bus is content addressed, so the final src is only known from the response and cannot +// be built from the upload path. +export async function uploadBase64Image(view, { src, path, fpoSrc }) { + const resp = await fetch(src); + const blob = await resp.blob(); + const { source } = await getNx2Api(); + const uploadResp = await source.uploadMedia(path, { body: blob }); + if (!uploadResp.ok) { + // eslint-disable-next-line no-console + console.error(`Failed to upload pasted image: ${uploadResp.status} ${uploadResp.statusText}`); + return; + } + const { source: { contentUrl } } = await uploadResp.json(); + + view.state.doc.descendants((node, pos) => { + if (node.type.name === 'image' && node.attrs.src === fpoSrc) { + view.dispatch(view.state.tr.setNodeMarkup(pos, null, { ...node.attrs, src: contentUrl })); + return false; + } + return true; + }); +} + /** * @param {{ * getSourceUrl: () => string | null, @@ -35,40 +55,16 @@ export default function base64Uploader({ getSourceUrl, getEditorView }) { const details = getSourceUploadContext(getSourceUrl() ?? ''); if (!details) return html; - const imagePaths = []; - const uploadPromises = []; - dataImgs.forEach((img) => { const src = img.getAttribute('src'); let ext = src.replace('data:image/', '').split(';base64')[0]; if (ext === 'jpeg') ext = 'jpg'; const path = `${details.parent}/.${details.name}/wp${makeHash(src)}.${ext}`; - const fpoSrc = `${FPO_IMG_URL}#${DA_CONTENT}${path}`; + const fpoSrc = `${FPO_IMG_URL}#${makeHash(src)}`; img.setAttribute('src', fpoSrc); - imagePaths.push(fpoSrc); - - uploadPromises.push((async () => { - const resp = await fetch(src); - const blob = await resp.blob(); - const body = new FormData(); - body.append('data', blob); - await daFetch(`${DA_ADMIN}/source${path}`, { body, method: 'POST' }); - })()); - }); - Promise.all(uploadPromises).then(() => { const view = getEditorView(); - if (!view) return; - const { tr } = view.state; - - view.state.doc.descendants((node, pos) => { - if (node.type.name === 'image' && imagePaths.includes(node.attrs.src)) { - const newAttrs = { src: node.attrs.src.split('#')[1] }; - tr.setNodeMarkup(pos, null, { ...node.attrs, ...newAttrs }); - } - }); - - view.dispatch(tr); + if (view) uploadBase64Image(view, { src, path, fpoSrc }); }); const serializer = new XMLSerializer(); diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js index 49602a954..997a50835 100644 --- a/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js +++ b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js @@ -1,5 +1,5 @@ import { Plugin, TextSelection } from 'da-y-wrapper'; -import { daFetch } from '../../../shared/utils.js'; +import { getNx2Api } from '../../../../scripts/utils.js'; import { getSourceUploadContext } from './sourceUploadContext.js'; const FPO_IMG_URL = '/blocks/edit/img/fpo.svg'; @@ -13,22 +13,31 @@ export async function uploadImageFile(view, file, details) { view.dispatch(view.state.tr.replaceSelectionWith(fpo).scrollIntoView()); const { $from } = view.state.selection; - const url = `${details.origin}/source${details.parent}/.${details.name}/${file.name}`; + const path = `${details.parent}/.${details.name}/${file.name}`; - const formData = new FormData(); - formData.append('data', file); - const resp = await daFetch(url, { method: 'PUT', body: formData }); + // the media bus is content addressed, so the src is only known from the response + const { source } = await getNx2Api(); + const resp = await source.uploadMedia(path, { body: file }); if (!resp.ok) return; - const json = await resp.json(); + const { source: { contentUrl } } = await resp.json(); - const docImg = document.createElement('img'); - docImg.addEventListener('load', () => { + const replaceFpo = () => { const fpoSelection = TextSelection.create(view.state.doc, $from.pos - 1, $from.pos); const ts = view.state.tr.setSelection(fpoSelection); - const img = schema.nodes.image.create({ src: json.source.contentUrl }); + const img = schema.nodes.image.create({ src: contentUrl }); view.dispatch(ts.replaceSelectionWith(img).scrollIntoView()); - }); - docImg.src = json.source.contentUrl; + }; + + // a media bus src is relative to the published page and cannot load from here, so waiting on it + // would leave the placeholder in the document + if (contentUrl.startsWith('./media_')) { + replaceFpo(); + return; + } + + const docImg = document.createElement('img'); + docImg.addEventListener('load', replaceFpo); + docImg.src = contentUrl; } /** diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js b/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js index 085a1dc23..e6e0564b1 100644 --- a/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js +++ b/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js @@ -1,30 +1,31 @@ /* * Copyright 2026 Adobe. All rights reserved. - * Derives upload parent/name from a DA source document URL (same shape as da.live getPathDetails). + * Derives the upload parent and name from a source document URL, in either store's shape. */ -import { getNx } from '../../../../scripts/utils.js'; -const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`); +// da-admin: https://admin.da.live/source/{org}/{site}/dir/doc.html +// source bus: https://api.aem.live/{org}/sites/{site}/source/dir/doc.html +function orgSiteAndRest(pathname) { + const segments = pathname.split('/').filter(Boolean); + if (segments[0] === 'source') return segments.slice(1); + if (segments[1] === 'sites' && segments[3] === 'source') { + return [segments[0], segments[2], ...segments.slice(4)]; + } + return null; +} /** - * @param {string} sourceUrl - e.g. https://admin.da.live/source/org/repo/path/doc.html - * @returns {{ origin: string, parent: string, name: string } | null} + * @param {string} sourceUrl a document url on either store + * @returns {{ parent: string, name: string } | null} the parent in the `/org/site/dir` form the + * source api takes, and the document name without its extension */ export function getSourceUploadContext(sourceUrl) { if (!sourceUrl || typeof sourceUrl !== 'string') return null; try { - const u = new URL(sourceUrl); - const mark = '/source/'; - const idx = u.pathname.indexOf(mark); - if (idx === -1) return null; - const rest = u.pathname.slice(idx + mark.length); - const segments = rest.split('/').filter(Boolean); - if (segments.length === 0) return null; - const lastSeg = segments[segments.length - 1]; - const name = lastSeg.replace(/\.html?$/i, ''); - const parentSegments = segments.slice(0, -1); - const parent = parentSegments.length ? `/${parentSegments.join('/')}` : '/'; - return { origin: DA_ADMIN, parent, name }; + const segments = orgSiteAndRest(new URL(sourceUrl).pathname); + if (!segments || segments.length < 3) return null; + const name = segments[segments.length - 1].replace(/\.html?$/i, ''); + return { parent: `/${segments.slice(0, -1).join('/')}`, name }; } catch { return null; } diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/image.js b/blocks/canvas/ew-editor-wysiwyg/utils/image.js index 770a2408c..9074bd697 100644 --- a/blocks/canvas/ew-editor-wysiwyg/utils/image.js +++ b/blocks/canvas/ew-editor-wysiwyg/utils/image.js @@ -1,8 +1,6 @@ -import { getNx } from '../../../../scripts/utils.js'; +import { getNx2Api } from '../../../../scripts/utils.js'; import { MESSAGE_TYPES } from '../../utils/quick-edit-messages.js'; -const { DA_ADMIN, DA_CONTENT } = await import(`${getNx()}/utils/utils.js`); - function updateImageInDocument(view, originalSrc, newSrc) { if (!view) return false; @@ -70,25 +68,11 @@ export async function handleImageReplace({ imageData, fileName, originalSrc }, c const pageName = getPageName(ctx.path); const parentPath = ctx.path === '/' ? '' : ctx.path.replace(/\/[^/]+$/, ''); - // Same upload path and URL as da-nx quick-edit-portal/src/images.js - const uploadPath = `${parentPath}/.${pageName}/${fileName}`; - const uploadUrl = `${DA_ADMIN}/source/${ctx.owner}/${ctx.repo}${uploadPath}`; - - const tokenPromise = typeof ctx.getToken === 'function' ? ctx.getToken() : null; - const token = tokenPromise != null && typeof tokenPromise?.then === 'function' - ? await tokenPromise - : tokenPromise; - const headers = {}; - if (token) headers.Authorization = `Bearer ${token}`; + // Same upload path as da-nx quick-edit-portal/src/images.js + const uploadPath = `/${ctx.owner}/${ctx.repo}${parentPath}/.${pageName}/${fileName}`; - const formData = new FormData(); - formData.append('data', blob, fileName); - - const resp = await fetch(uploadUrl, { - method: 'PUT', - body: formData, - headers, - }); + const { source } = await getNx2Api(); + const resp = await source.uploadMedia(uploadPath, { body: blob }); if (!resp.ok) { const error = `Upload failed with status ${resp.status}`; @@ -99,8 +83,8 @@ export async function handleImageReplace({ imageData, fileName, originalSrc }, c return; } - // Same as da-nx: AEM delivery URL for the uploaded image - const newSrc = `${DA_CONTENT}/${ctx.owner}/${ctx.repo}${uploadPath}`; + // the media bus is content addressed, so the src is only known from the response + const { source: { contentUrl: newSrc } } = await resp.json(); updateImageInDocument(ctx.view, originalSrc, newSrc); From 5b7053aae33003f11060a48d0435375d3f8f2281 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Sat, 15 Aug 2026 15:12:00 +0200 Subject: [PATCH 09/19] fix(canvas): check the sign-in before the store, and read da-admin with the retrying fetcher --- blocks/canvas/canvas.js | 3 +- blocks/canvas/ew-editor-doc/ew-editor-doc.js | 6 +- .../ew-editor-doc/utils/load-editor-doc.js | 16 +++++- blocks/canvas/ew-editor-doc/utils/source.js | 8 ++- .../ew-editor-doc/session-order.test.js | 56 +++++++++++++++++++ .../canvas/ew-editor-doc/utils/source.test.js | 11 +++- 6 files changed, 88 insertions(+), 12 deletions(-) create mode 100644 test/unit/blocks/canvas/ew-editor-doc/session-order.test.js diff --git a/blocks/canvas/canvas.js b/blocks/canvas/canvas.js index 4c0fa071c..14a4f6542 100644 --- a/blocks/canvas/canvas.js +++ b/blocks/canvas/canvas.js @@ -15,7 +15,6 @@ import { removeSplitGutter, } from './ew-editor-split/ew-editor-split.js'; import { resolveEditorDocSession } from './ew-editor-doc/utils/load-editor-doc.js'; -import { sourceUrlFromEditorCtx } from './ew-editor-doc/utils/ctx.js'; import { SEL_BLOCK, SEL_ITEM, SEL_TEXT } from './ew-editor-doc/utils/selection.js'; import { getChatPanelContent } from '../shared/chat-panel.js'; import { canvasBus } from './utils/canvas-bus.js'; @@ -108,7 +107,7 @@ async function syncCanvasEditorsToHash({ mountRoot, header, state }) { return; } const ctx = editorCtxFromHashState(state, fullPath); - const session = await resolveEditorDocSession(await sourceUrlFromEditorCtx(ctx)); + const session = await resolveEditorDocSession(ctx); if (loadCount !== editorLoadCount) return; if (!session.ok) { removeCanvasEditors(mountRoot); diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js index 8cf9ba9d4..24f1b3e07 100644 --- a/blocks/canvas/ew-editor-doc/ew-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js @@ -5,7 +5,6 @@ import { updateDocument, updateCursors, getInstrumentedHTML, getEditor } from '. import { getActiveBlockIndex, getBlockPositions } from '../editor-utils/blocks.js'; import { editorDocCanLoad, - sourceUrlFromEditorCtx, controllerPathnameFromEditorCtx, editorDocRenderPhase, } from './utils/ctx.js'; @@ -266,13 +265,12 @@ export class EwEditorDoc extends LitElement { return; } - const sourceUrl = await sourceUrlFromEditorCtx(this.ctx); - - const session = this.session ?? await resolveEditorDocSession(sourceUrl); + const session = this.session ?? await resolveEditorDocSession(this.ctx); if (!session.ok) { this._error = session.error; return; } + const { sourceUrl } = session; try { const { token, permissions } = session; diff --git a/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js index 31db5afe2..0ed9afd04 100644 --- a/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js @@ -1,4 +1,5 @@ import { checkDoc } from './source.js'; +import { sourceUrlFromEditorCtx } from './ctx.js'; import { initIms } from '../../../shared/utils.js'; export function sessionErrorFromResponse(resp) { @@ -12,17 +13,28 @@ export function sessionErrorFromResponse(resp) { return { ok: false, error: `Could not load the document (${status})${reason}` }; } -export async function resolveEditorDocSession(sourceUrl) { +// Takes the ctx rather than a url, so the store lookup happens after the sign-in check: it needs a +// token of its own, and an anonymous visitor would otherwise be sent to sign in by the lookup +// before this can say so. +export async function resolveEditorDocSession(ctx) { const ims = await initIms(); const token = ims?.accessToken?.token ?? null; if (ims?.anonymous || !token) { return { ok: false, error: 'Sign in required' }; } + let sourceUrl; + try { + sourceUrl = await sourceUrlFromEditorCtx(ctx); + } catch { + return { ok: false, error: 'Could not reach the content store' }; + } + if (!sourceUrl) return { ok: false, error: 'Could not reach the content store' }; + const resp = await checkDoc(sourceUrl); const failure = sessionErrorFromResponse(resp); if (failure) return failure; const permissions = resp.permissions || ['read']; - return { ok: true, token, permissions }; + return { ok: true, token, permissions, sourceUrl }; } diff --git a/blocks/canvas/ew-editor-doc/utils/source.js b/blocks/canvas/ew-editor-doc/utils/source.js index e000d0684..b22d12634 100644 --- a/blocks/canvas/ew-editor-doc/utils/source.js +++ b/blocks/canvas/ew-editor-doc/utils/source.js @@ -1,4 +1,5 @@ import { getNx, getNx2Api } from '../../../../scripts/utils.js'; +import { daFetch } from '../../../shared/utils.js'; const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`); @@ -19,7 +20,10 @@ export async function buildSourceUrl(path) { return `${DA_ADMIN}/source/${trimmed}.html`; } +// da-admin keeps da-live's own fetcher, which reads the token live and retries once on a 401. +// nx2's is the one that allowlists api.aem.live for the bearer. export async function checkDoc(sourceUrl) { - const { daFetch } = await getNx2Api(); - return daFetch({ url: sourceUrl, opts: { method: 'HEAD' } }); + if (sourceUrl.startsWith(DA_ADMIN)) return daFetch(sourceUrl, { method: 'HEAD' }); + const { daFetch: nx2Fetch } = await getNx2Api(); + return nx2Fetch({ url: sourceUrl, opts: { method: 'HEAD' } }); } diff --git a/test/unit/blocks/canvas/ew-editor-doc/session-order.test.js b/test/unit/blocks/canvas/ew-editor-doc/session-order.test.js new file mode 100644 index 000000000..b6a9dae9d --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/session-order.test.js @@ -0,0 +1,56 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let resolveEditorDocSession; + +before(async () => { + ({ resolveEditorDocSession } = await import('../../../../../blocks/canvas/ew-editor-doc/utils/load-editor-doc.js')); +}); + +function stubFetch(respond) { + const saved = window.fetch; + const calls = []; + window.fetch = async (url, opts) => { + const href = typeof url === 'string' ? url : url.url; + calls.push({ url: href, opts }); + return respond(href, opts); + }; + return { calls, restore: () => { window.fetch = saved; } }; +} + +const ok = () => new Response('', { status: 200 }); + +afterEach(() => { + window.localStorage.removeItem('hlx6-upgrade'); + window.localStorage.removeItem('nx-ims'); + delete window.adobeIMS; +}); + +describe('resolveEditorDocSession', () => { + // the nx fixture's loadIms answers nothing, so every session in this file is anonymous + it('refuses an anonymous session before it looks the store up', async () => { + const { calls, restore } = stubFetch(ok); + try { + const session = await resolveEditorDocSession({ org: 'anonorg', repo: 'anonsite', path: '/anonorg/anonsite/page' }); + + expect(session).to.deep.equal({ ok: false, error: 'Sign in required' }); + expect(calls, 'the store was asked before the sign-in check').to.have.length(0); + } finally { + restore(); + } + }); + + it('answers the same for a ctx it cannot use', async () => { + const { calls, restore } = stubFetch(ok); + try { + const session = await resolveEditorDocSession({ org: 'anonorg', repo: 'anonsite', path: '' }); + + expect(session.ok).to.equal(false); + expect(calls).to.have.length(0); + } finally { + restore(); + } + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js b/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js index cbb00302b..c6e44d5f0 100644 --- a/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js +++ b/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js @@ -68,6 +68,7 @@ describe('canvas buildSourceUrl', () => { }); describe('canvas checkDoc', () => { + // the source bus is not on da-live's token allowlist, so that read goes through nx2 it('asks api.aem.live for a source-bus document, with a bearer', async () => { const { calls, restore } = stubPing({ upgraded: true }); try { @@ -82,7 +83,11 @@ describe('canvas checkDoc', () => { } }); - it('asks da-admin for a legacy document, with a bearer', async () => { + // da-admin keeps da-live's own fetcher, which reads the token live and retries once on a 401. + // nx2's takes the snapshot loadIms captured at page load and does neither. + it('asks da-admin for a legacy document, through the fetcher that can refresh a token', async () => { + window.localStorage.setItem('nx-ims', 'true'); + window.adobeIMS = { getAccessToken: () => ({ token: 'live-token' }) }; const { calls, restore } = stubPing({ upgraded: false }); try { const url = await buildSourceUrl('/checkleg/checkleg/page'); @@ -90,9 +95,11 @@ describe('canvas checkDoc', () => { const head = calls.find((c) => c.opts?.method === 'HEAD'); expect(head, 'no HEAD was issued').to.exist; expect(head.url).to.equal('https://admin.da.live/source/checkleg/checkleg/page.html'); - expect(head.opts.headers.Authorization).to.equal('Bearer test-token'); + expect(new Headers(head.opts.headers).get('Authorization')).to.equal('Bearer live-token'); } finally { restore(); + window.localStorage.removeItem('nx-ims'); + delete window.adobeIMS; } }); From 94601ea998bc2f3affeeef53504649ba3558a52a Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Sat, 15 Aug 2026 15:53:00 +0200 Subject: [PATCH 10/19] test(canvas): media images render from the origin the canvas logged into --- .../ew-editor-doc/prose-plugins/media-bus-image.test.js | 7 ++++--- .../canvas/ew-editor-doc/prose-plugins/upload.test.js | 9 +++++++-- .../blocks/canvas/ew-editor-wysiwyg/image-upload.test.js | 8 ++++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/media-bus-image.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/media-bus-image.test.js index 5de4b0f90..2c28438bf 100644 --- a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/media-bus-image.test.js +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/media-bus-image.test.js @@ -15,9 +15,10 @@ before(async () => { }); describe('canvas getRenderableSrc', () => { - it('rewrites a relative media src to the preview origin', () => { + // the same origin the canvas fetches its preview cookie from, which is stage off da.live + it('rewrites a relative media src to the origin the canvas logged into', () => { expect(getRenderableSrc('./media_123.png', ctx)).to.equal( - 'https://main--repo--org.preview.da.live/media_123.png', + 'https://main--repo--org.stage-preview.da.live/media_123.png', ); }); @@ -52,7 +53,7 @@ describe('canvas mediaBusImage plugin', () => { await nextFrame(); const img = editor.view.dom.querySelector('img'); - expect(img.src).to.equal('https://main--repo--org.preview.da.live/media_123.png'); + expect(img.src).to.equal('https://main--repo--org.stage-preview.da.live/media_123.png'); let storedSrc; editor.view.state.doc.descendants((node) => { diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js index 82556c7c2..e2aff3c12 100644 --- a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js @@ -84,9 +84,13 @@ describe('uploadImageFile', () => { try { await uploadImageFile(editor.view, png(), { parent: '/upsorg/upssite/dir', name: 'doc' }); + // nx2 owns the route it builds from the path, so what is pinned here is the store it went + // to and the path the canvas handed it const upload = calls.find((c) => c.opts?.method === 'POST'); expect(upload, 'nothing was uploaded').to.exist; - expect(upload.url).to.equal('https://api.aem.live/upsorg/sites/upssite/source/dir/.doc/pic.png'); + expect(new URL(upload.url).origin).to.equal('https://api.aem.live'); + expect(upload.url).to.contain('/upsorg/sites/upssite/'); + expect(upload.url.endsWith('/dir/.doc/pic.png'), upload.url).to.equal(true); } finally { restore(); } @@ -99,7 +103,8 @@ describe('uploadImageFile', () => { const upload = calls.find((c) => c.opts?.method === 'POST'); expect(upload, 'nothing was uploaded').to.exist; - expect(upload.url).to.equal('https://admin.da.live/source/legorg/legsite/dir/.doc/pic.png'); + expect(new URL(upload.url).origin).to.equal('https://admin.da.live'); + expect(upload.url.endsWith('/legorg/legsite/dir/.doc/pic.png'), upload.url).to.equal(true); } finally { restore(); } diff --git a/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js b/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js index 5258888b6..8b308cc34 100644 --- a/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js +++ b/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js @@ -71,9 +71,12 @@ describe('handleImageReplace', () => { try { await handleImageReplace({ imageData, fileName: 'pic.png', originalSrc: '/old.png' }, ctx); + // the route past the site is nx2's, so what is pinned is the store and the path const upload = calls.find((c) => c.opts?.method === 'POST'); expect(upload, 'nothing was uploaded').to.exist; - expect(upload.url).to.equal('https://api.aem.live/wysorg/sites/wyssite/source/.page/pic.png'); + expect(new URL(upload.url).origin).to.equal('https://api.aem.live'); + expect(upload.url).to.contain('/wysorg/sites/wyssite/'); + expect(upload.url.endsWith('/.page/pic.png'), upload.url).to.equal(true); } finally { restore(); } @@ -87,7 +90,8 @@ describe('handleImageReplace', () => { const upload = calls.find((c) => c.opts?.method === 'POST'); expect(upload, 'nothing was uploaded').to.exist; - expect(upload.url).to.equal('https://admin.da.live/source/wyslegacy/wyslegacy/.page/pic.png'); + expect(new URL(upload.url).origin).to.equal('https://admin.da.live'); + expect(upload.url.endsWith('/wyslegacy/wyslegacy/.page/pic.png'), upload.url).to.equal(true); } finally { restore(); } From 57e8f952c5fcacfb29f08eb4f7417d6732d126ff Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Sat, 15 Aug 2026 16:52:00 +0200 Subject: [PATCH 11/19] fix(canvas): render media images from getPreviewOrigin, not getLivePreviewUrl --- .../ew-editor-doc/prose-plugins/base64Uploader.js | 3 +-- .../canvas/ew-editor-doc/prose-plugins/imageDrop.js | 3 +-- .../ew-editor-doc/prose-plugins/mediaBusImage.js | 11 +++++------ blocks/canvas/ew-editor-doc/utils/load-editor-doc.js | 4 +--- .../canvas/ew-editor-doc/prose-plugins/upload.test.js | 9 +++------ .../blocks/canvas/ew-editor-doc/prose-room.test.js | 3 +-- .../blocks/canvas/ew-editor-doc/utils/ctx.test.js | 2 +- .../blocks/canvas/ew-editor-doc/utils/source.test.js | 4 +--- 8 files changed, 14 insertions(+), 25 deletions(-) diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js index 69fa37218..5c068b4d2 100644 --- a/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js +++ b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js @@ -11,8 +11,7 @@ function makeHash(string) { ), 0)); } -// The media bus is content addressed, so the final src is only known from the response and cannot -// be built from the upload path. +// the media bus is content addressed, so the src is only known from the response export async function uploadBase64Image(view, { src, path, fpoSrc }) { const resp = await fetch(src); const blob = await resp.blob(); diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js index 997a50835..fe9320acd 100644 --- a/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js +++ b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js @@ -28,8 +28,7 @@ export async function uploadImageFile(view, file, details) { view.dispatch(ts.replaceSelectionWith(img).scrollIntoView()); }; - // a media bus src is relative to the published page and cannot load from here, so waiting on it - // would leave the placeholder in the document + // a media bus src is relative to the published page and cannot load from here if (contentUrl.startsWith('./media_')) { replaceFpo(); return; diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js b/blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js index 0f030a498..f83c32092 100644 --- a/blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js +++ b/blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js @@ -1,18 +1,17 @@ // eslint-disable-next-line import/no-unresolved import { Plugin, PluginKey } from 'da-y-wrapper'; -import { getLivePreviewUrl } from '../../../shared/constants.js'; +import { getPreviewOrigin } from '../../editor-utils/editor-utils.js'; const mediaBusImageKey = new PluginKey('canvasMediaBusImage'); -// A document on the media bus stores an image src relative to the published page -// ("./media_123.png"), which only resolves where the page is served from. The canvas runs on -// another origin, so the src is rewritten to the doc's preview origin for display. The node attrs -// keep the relative path, and so does the saved document. +// a media bus src ("./media_123.png") only resolves where the page is served, so it is rewritten +// to the doc's preview origin for display; node attrs and the saved document keep the relative +// path. getPreviewOrigin matches the origin fetchWysiwygCookie logs into, unlike getLivePreviewUrl. export function getRenderableSrc(src, ctx) { if (!src || !src.startsWith('./media_')) return null; const { org, repo } = ctx ?? {}; if (!org || !repo) return null; - return `${getLivePreviewUrl(org, repo)}/${src.slice(2)}`; + return `${getPreviewOrigin(org, repo)}/${src.slice(2)}`; } function updateImageSrcs(view, ctx) { diff --git a/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js index 0ed9afd04..ca9069913 100644 --- a/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js @@ -13,9 +13,7 @@ export function sessionErrorFromResponse(resp) { return { ok: false, error: `Could not load the document (${status})${reason}` }; } -// Takes the ctx rather than a url, so the store lookup happens after the sign-in check: it needs a -// token of its own, and an anonymous visitor would otherwise be sent to sign in by the lookup -// before this can say so. +// takes the ctx rather than a url, so the sign-in check runs before the store lookup needs a token export async function resolveEditorDocSession(ctx) { const ims = await initIms(); const token = ims?.accessToken?.token ?? null; diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js index e2aff3c12..808882bac 100644 --- a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js @@ -14,8 +14,7 @@ before(async () => { ({ uploadImageFile } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js')); }); -// isHlx6 pings admin.hlx.page, and the upload goes to whichever store that names. Each case needs -// its own org/site because the answer is memoized per site. +// isHlx6 memoizes its answer per site, so each case needs its own org/site function stubStore({ upgraded, contentUrl = 'https://content.da.live/org/site/.doc/pic.png' }) { const saved = window.fetch; const calls = []; @@ -84,8 +83,7 @@ describe('uploadImageFile', () => { try { await uploadImageFile(editor.view, png(), { parent: '/upsorg/upssite/dir', name: 'doc' }); - // nx2 owns the route it builds from the path, so what is pinned here is the store it went - // to and the path the canvas handed it + // nx2 owns the route past the site, so this pins only the store and the path const upload = calls.find((c) => c.opts?.method === 'POST'); expect(upload, 'nothing was uploaded').to.exist; expect(new URL(upload.url).origin).to.equal('https://api.aem.live'); @@ -111,8 +109,7 @@ describe('uploadImageFile', () => { }); it('shows a media bus image without waiting for it to load', async () => { - // a relative src cannot load from the canvas origin, so waiting on it would leave the - // placeholder in the document for good + // a relative src cannot load from the canvas origin const { restore } = stubStore({ upgraded: true, contentUrl: './media_abc.png' }); try { await uploadImageFile(editor.view, png(), { parent: '/relorg/relsite', name: 'doc' }); diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-room.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-room.test.js index 6a02d703f..4a5d90636 100644 --- a/test/unit/blocks/canvas/ew-editor-doc/prose-room.test.js +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-room.test.js @@ -37,8 +37,7 @@ describe('canvas collab room', () => { }); it('puts a source-bus document in its api.aem.live room', async () => { - // da-collab reads the store off the room name, so a da-admin room for a - // source-bus document edits the copy the site does not serve. + // da-collab reads the store off the room name, so the room must name the document's real store const result = await connect('https://api.aem.live/roomorg/sites/roomsite/source/page.html'); try { expect(result.wsProvider.roomname).to.equal('https://api.aem.live/roomorg/sites/roomsite/source/page.html'); diff --git a/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js b/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js index 42557e54f..0dd28ff12 100644 --- a/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js +++ b/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js @@ -42,7 +42,7 @@ describe('sourceUrlFromEditorCtx', () => { describe('editorDocCanLoad', () => { it('is decided without a network call', () => { - // A promise is truthy, so this has to stay synchronous or an empty path reads as loadable. + // a promise is truthy, so this has to stay synchronous or an empty path reads as loadable expect(editorDocCanLoad({ org: 'o', repo: 's', path: '/o/s/page' })).to.equal(true); }); diff --git a/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js b/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js index c6e44d5f0..817d55fe9 100644 --- a/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js +++ b/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js @@ -10,9 +10,7 @@ before(async () => { ({ buildSourceUrl, checkDoc } = await import('../../../../../../blocks/canvas/ew-editor-doc/utils/source.js')); }); -// isHlx6 pings admin.hlx.page and reads the upgrade header off the response, so a -// stubbed fetch decides which store a site is on. Each case needs its own org/site -// because the answer is memoized per site for the life of the page. +// isHlx6 memoizes its answer per site, so each case needs its own org/site function stubPing({ upgraded }) { const saved = window.fetch; const calls = []; From 605a7fe50bce36afa8811f2ad8d0a0912935631a Mon Sep 17 00:00:00 2001 From: Markus Haack Date: Mon, 17 Aug 2026 11:53:08 +0200 Subject: [PATCH 12/19] fix(canvas): remove leftover debug log from handleImageReplace --- blocks/canvas/ew-editor-wysiwyg/utils/image.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/image.js b/blocks/canvas/ew-editor-wysiwyg/utils/image.js index 9074bd697..e46e0aa99 100644 --- a/blocks/canvas/ew-editor-wysiwyg/utils/image.js +++ b/blocks/canvas/ew-editor-wysiwyg/utils/image.js @@ -60,9 +60,6 @@ export async function handleImageReplace({ imageData, fileName, originalSrc }, c ctx.suppressRerender = true; try { - // eslint-disable-next-line no-console - console.log('handleImageReplace', fileName, originalSrc); - const blob = dataUrlToBlob(imageData); const pageName = getPageName(ctx.path); From 0b6b765073c6c0f8c22ee80f5416745aa95d6e90 Mon Sep 17 00:00:00 2001 From: Markus Haack Date: Mon, 17 Aug 2026 12:22:15 +0200 Subject: [PATCH 13/19] fix(shared): route fetchDaConfigs through getNx2Api's config API fetchDaConfigs previously always hit admin.da.live/config/... directly via the legacy daFetch helper, bypassing the isHlx6-aware routing the rest of the canvas/edit/browse migration already uses (source, status, versions, etc). It now calls getNx2Api().config.get({org, site}), which pings isHlx6 and, once nx2's config route is fully HLX6-aware, will resolve to the site's real store the same way source.get/save already do. Behavior for legacy (non-upgraded) sites is unchanged. Every getNx2Api().config.get call pings isHlx6 first (an extra HLX_ADMIN/ping/{org}/{site} fetch), which several existing test mocks didn't account for (some also matched on a shorter config URL pattern that collided with the ping's own /org/site/ path). Updated those mocks in aem-assets.test.js, edit/da-assets/config.test.js, edit/da-content/helpers/index.test.js, browse/da-browse.test.js and edit/prose/plugins/imageFocalPoint.test.js to answer the ping safely, and added direct coverage for the new routing in blocks/shared/utils.test.js. --- blocks/shared/utils.js | 12 +++++--- .../blocks/browse/da-browse/da-browse.test.js | 10 ++++--- .../ew-panel-extensions/aem-assets.test.js | 6 +++- .../unit/blocks/edit/da-assets/config.test.js | 29 ++++++++++++++----- .../edit/da-content/helpers/index.test.js | 25 +++++++++++----- .../prose/plugins/imageFocalPoint.test.js | 5 ++-- test/unit/blocks/shared/utils.test.js | 29 +++++++++++++++++++ 7 files changed, 89 insertions(+), 27 deletions(-) diff --git a/blocks/shared/utils.js b/blocks/shared/utils.js index fc6606c52..c183d7538 100644 --- a/blocks/shared/utils.js +++ b/blocks/shared/utils.js @@ -342,8 +342,12 @@ export async function checkLockdownImages(owner) { export const fetchDaConfigs = (() => { const configCache = {}; - const fetchConfig = async (pathname) => { - const resp = await daFetch(`${DA_ORIGIN}/config${pathname}/`); + // getNx2Api's config.get is isHlx6-aware: it reads from api.aem.live for a + // migrated org/site, and falls back to admin.da.live otherwise. + const fetchConfig = async (org, site) => { + const { config: configApi } = await getNx2Api(); + const resp = await configApi.get({ org, site }); + const pathname = site ? `/${org}/${site}` : `/${org}`; if (!resp.ok) return { error: `Error loading ${pathname}`, status: resp.status }; return resp.json(); }; @@ -352,11 +356,11 @@ export const fetchDaConfigs = (() => { if (!org) return [Promise.resolve(null)]; // Set the org config promise if it does not exist - configCache[`/${org}`] ??= fetchConfig(`/${org}`); + configCache[`/${org}`] ??= fetchConfig(org); if (site) { // Set the site config promise if it does not exist - configCache[`/${org}/${site}`] ??= fetchConfig(`/${org}/${site}`); + configCache[`/${org}/${site}`] ??= fetchConfig(org, site); } // return array of cached configs (org = 0, site = 1) diff --git a/test/unit/blocks/browse/da-browse/da-browse.test.js b/test/unit/blocks/browse/da-browse/da-browse.test.js index 9d01eb4f9..b1076cd49 100644 --- a/test/unit/blocks/browse/da-browse/da-browse.test.js +++ b/test/unit/blocks/browse/da-browse/da-browse.test.js @@ -347,10 +347,12 @@ describe('DaBrowse Component', () => { describe('getEditor', () => { function mockConfig(rows) { - window.fetch = async () => ({ - ok: true, - json: async () => ({ data: rows }), - }); + // getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); answer that + // with a real Response (so its headers.get() call is safe) and defer everything else. + window.fetch = async (url) => { + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); + return { ok: true, json: async () => ({ data: rows }) }; + }; } let origFetch; diff --git a/test/unit/blocks/canvas/ew-panel-extensions/aem-assets.test.js b/test/unit/blocks/canvas/ew-panel-extensions/aem-assets.test.js index 8ddcfc822..e7a4886df 100644 --- a/test/unit/blocks/canvas/ew-panel-extensions/aem-assets.test.js +++ b/test/unit/blocks/canvas/ew-panel-extensions/aem-assets.test.js @@ -25,12 +25,16 @@ function makeSheet(entries) { function makeFetch(responses) { return async (url) => { + // getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); check that + // before the pattern match below, since a ping url can otherwise collide with an + // org-level config pattern (e.g. '/ping/{org}/{site}' contains '/{org}/'). + if (url.includes('/ping/')) return new Response('', { status: 200 }); for (const [pattern, response] of Object.entries(responses).sort( ([a], [b]) => b.length - a.length, )) { if (url.includes(pattern)) return response; } - return { ok: false }; + return new Response('', { status: 404 }); }; } diff --git a/test/unit/blocks/edit/da-assets/config.test.js b/test/unit/blocks/edit/da-assets/config.test.js index 53338a6af..a557510ae 100644 --- a/test/unit/blocks/edit/da-assets/config.test.js +++ b/test/unit/blocks/edit/da-assets/config.test.js @@ -15,10 +15,14 @@ function makeSheet(entries) { function makeFetch(responses) { return async (url) => { + // getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); check that + // before the pattern match below, since a ping url can otherwise collide with an + // org-level config pattern (e.g. '/ping/{org}/{site}' contains '/{org}/'). + if (url.includes('/ping/')) return new Response('', { status: 200 }); for (const [pattern, response] of Object.entries(responses)) { if (url.includes(pattern)) return response; } - return { ok: false }; + return new Response('', { status: 404 }); }; } @@ -418,6 +422,15 @@ function makeMultiSheetWithResponsive(crops) { }; } +// getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); answer that with a +// real Response (so its headers.get() call is safe) and defer everything else to `respond`. +function pingSafe(respond) { + return async (url, opts) => { + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); + return respond(url, opts); + }; +} + describe('getResponsiveImageConfig', () => { it('returns null when owner and repo are both absent', async () => { const result = await getResponsiveImageConfig(null, null); @@ -426,7 +439,7 @@ describe('getResponsiveImageConfig', () => { it('returns false when config has no responsive-images sheet', async () => { const orgFetch = window.fetch; - window.fetch = async () => ({ ok: true, json: async () => ({ data: [] }) }); + window.fetch = pingSafe(async () => ({ ok: true, json: async () => ({ data: [] }) })); try { const result = await getResponsiveImageConfig('ri1', 'none'); expect(result).to.be.false; @@ -437,9 +450,9 @@ describe('getResponsiveImageConfig', () => { it('parses crops string into array from responsive-images sheet', async () => { const orgFetch = window.fetch; - window.fetch = async () => makeMultiSheetWithResponsive([ + window.fetch = pingSafe(async () => makeMultiSheetWithResponsive([ { name: 'Full Width', position: 'everywhere', crops: 'desktop, mobile' }, - ]); + ])); try { const result = await getResponsiveImageConfig('ri2', 'crops'); expect(result).to.be.an('array'); @@ -452,9 +465,9 @@ describe('getResponsiveImageConfig', () => { it('handles crops with no spaces around comma', async () => { const orgFetch = window.fetch; - window.fetch = async () => makeMultiSheetWithResponsive([ + window.fetch = pingSafe(async () => makeMultiSheetWithResponsive([ { name: 'Tight', position: 'hero', crops: 'small,medium,large' }, - ]); + ])); try { const result = await getResponsiveImageConfig('ri3', 'tight'); expect(result[0].crops).to.deep.equal(['small', 'medium', 'large']); @@ -465,12 +478,12 @@ describe('getResponsiveImageConfig', () => { it('falls back to org-level config when repo config has no responsive-images', async () => { const orgFetch = window.fetch; - window.fetch = async (url) => { + window.fetch = pingSafe((url) => { if (url.includes('/ri4/fallback/')) return { ok: true, json: async () => ({ data: [] }) }; return makeMultiSheetWithResponsive([ { name: 'Org Wide', position: 'outside-blocks', crops: 'wide' }, ]); - }; + }); try { const result = await getResponsiveImageConfig('ri4', 'fallback'); expect(result[0].name).to.equal('Org Wide'); diff --git a/test/unit/blocks/edit/da-content/helpers/index.test.js b/test/unit/blocks/edit/da-content/helpers/index.test.js index dad5c9cf5..15d01c51e 100644 --- a/test/unit/blocks/edit/da-content/helpers/index.test.js +++ b/test/unit/blocks/edit/da-content/helpers/index.test.js @@ -17,9 +17,18 @@ const MULTI_SHEET = { data: SINGLE_SHEET }; const { default: ueUrlHelper } = await import('../../../../../../blocks/edit/da-content/helpers/index.js'); +// getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); answer that with a +// real Response (so its headers.get() call is safe) and defer everything else to `respond`. +function pingSafe(respond) { + return async (url, opts) => { + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); + return respond(url, opts); + }; +} + describe('UE URLs', () => { it('Supports single sheet configs', async () => { - const mockFetch = async () => ({ ok: true, json: async () => (SINGLE_SHEET) }); + const mockFetch = pingSafe(async () => ({ ok: true, json: async () => (SINGLE_SHEET) })); const orgFetch = window.fetch; try { @@ -32,7 +41,7 @@ describe('UE URLs', () => { }); it('Supports multisheet configs', async () => { - const mockFetch = async () => ({ ok: true, json: async () => (MULTI_SHEET) }); + const mockFetch = pingSafe(async () => ({ ok: true, json: async () => (MULTI_SHEET) })); const orgFetch = window.fetch; try { @@ -45,7 +54,7 @@ describe('UE URLs', () => { }); it('Successfully dies gracefully', async () => { - const mockFetch = async () => ({ ok: false }); + const mockFetch = pingSafe(async () => ({ ok: false })); const orgFetch = window.fetch; try { @@ -60,7 +69,7 @@ describe('UE URLs', () => { it('Returns null when no editor.path or quick-edit config exists', async () => { const orgFetch = window.fetch; try { - window.fetch = async () => ({ ok: true, json: async () => ({ data: [{ key: 'other', value: 'x' }] }) }); + window.fetch = pingSafe(async () => ({ ok: true, json: async () => ({ data: [{ key: 'other', value: 'x' }] }) })); const url = await ueUrlHelper('org', 'repo', 'https://main--repo--org.aem.page/page'); expect(url).to.equal(null); } finally { @@ -71,10 +80,10 @@ describe('UE URLs', () => { it('Builds a quick-edit URL when quick-edit config matches the repo', async () => { const orgFetch = window.fetch; try { - window.fetch = async () => ({ + window.fetch = pingSafe(async () => ({ ok: true, json: async () => ({ data: [{ key: 'quick-edit', value: 'repo' }] }), - }); + })); const url = await ueUrlHelper('org-qe', 'repo', 'https://main--repo--org.aem.live/page'); expect(url).to.equal('https://main--repo--org.aem.page/page?quick-edit=on'); } finally { @@ -85,10 +94,10 @@ describe('UE URLs', () => { it('Strips trailing /index when building the quick-edit URL', async () => { const orgFetch = window.fetch; try { - window.fetch = async () => ({ + window.fetch = pingSafe(async () => ({ ok: true, json: async () => ({ data: [{ key: 'quick-edit', value: 'repo' }] }), - }); + })); const url = await ueUrlHelper('org-qe-strip', 'repo', 'https://main--repo--org.aem.live/folder/index'); expect(url).to.equal('https://main--repo--org.aem.page/folder/?quick-edit=on'); } finally { diff --git a/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js b/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js index a72d5af4d..725e74765 100644 --- a/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js +++ b/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js @@ -54,8 +54,9 @@ describe('imageFocalPoint Plugin', () => { }), }; } - // Fallback for any other requests - return { ok: false }; + // Fallback for any other requests (includes getNx2Api's isHlx6 ping preflight, + // which needs a real Response for its headers.get() call) + return new Response('', { status: 404 }); }; const mod = await import('../../../../../../blocks/edit/prose/plugins/imageFocalPoint.js'); diff --git a/test/unit/blocks/shared/utils.test.js b/test/unit/blocks/shared/utils.test.js index 20365b4b5..f80aa95ab 100644 --- a/test/unit/blocks/shared/utils.test.js +++ b/test/unit/blocks/shared/utils.test.js @@ -684,6 +684,35 @@ describe('fetchDaConfigs', () => { expect(resolved).to.equal(null); expect(fetchCalled).to.be.false; }); + + // fetchDaConfigs routes through getNx2Api's config.get, which pings isHlx6 + // (HLX_ADMIN/ping/{org}/{site}) before resolving the config url. + it('goes through the isHlx6-aware config route, not a hardcoded da-admin fetch', async () => { + const calls = []; + window.fetch = async (url) => { + calls.push(String(url)); + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); + return new Response(JSON.stringify({ data: [{ key: 'k', value: 'v' }] }), { status: 200 }); + }; + + const [orgConfig, siteConfig] = await Promise.all( + fetchDaConfigs({ org: 'cfgorg', site: 'cfgsite' }), + ); + + expect(calls.some((u) => u.includes('/ping/cfgorg/cfgsite'))).to.equal(true, 'isHlx6 was not consulted'); + expect(orgConfig).to.deep.equal({ data: [{ key: 'k', value: 'v' }] }); + expect(siteConfig).to.deep.equal({ data: [{ key: 'k', value: 'v' }] }); + }); + + it('reports the store status without throwing when the config fetch fails', async () => { + window.fetch = async (url) => { + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); + return new Response('', { status: 500 }); + }; + + const [orgConfig] = await Promise.all(fetchDaConfigs({ org: 'cfgfail' })); + expect(orgConfig).to.deep.equal({ error: 'Error loading /cfgfail', status: 500 }); + }); }); describe('saveDaVersion', () => { From 2e70997788f16cbef32c3f1563a649172ff7822b Mon Sep 17 00:00:00 2001 From: Markus Haack Date: Mon, 17 Aug 2026 17:51:17 +0200 Subject: [PATCH 14/19] fix(canvas): route getPreviewStatus through getNx2Api's status.get getPreviewStatus in ew-panel-extensions/helpers.js still hit legacy admin.hlx.page direct via aemAdmin(), no isHlx6 branch. Mirrors the already-fixed sibling in blocks/edit/da-library/helpers/helpers.js: now uses getNx2Api().status.get(path), which resolves to the site's real store (admin.hlx.page or api.aem.live) based on isHlx6. Response shape (json.preview.status) unchanged. --- blocks/canvas/ew-panel-extensions/helpers.js | 10 +++-- .../ew-panel-extensions/helpers.test.js | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/blocks/canvas/ew-panel-extensions/helpers.js b/blocks/canvas/ew-panel-extensions/helpers.js index b013af80f..8b009b8cf 100644 --- a/blocks/canvas/ew-panel-extensions/helpers.js +++ b/blocks/canvas/ew-panel-extensions/helpers.js @@ -1,7 +1,7 @@ /* eslint-disable import/no-unresolved -- importmap */ import { DOMParser as PMDOMParser, DOMSerializer, Slice, TextSelection } from 'da-y-wrapper'; -import { getNx } from '../../../scripts/utils.js'; -import { aemAdmin, daFetch } from '../../shared/utils.js'; +import { getNx, getNx2Api } from '../../../scripts/utils.js'; +import { daFetch } from '../../shared/utils.js'; import { htmlToProse } from '../../edit/utils/helpers.js'; import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; @@ -394,8 +394,10 @@ export async function insertTemplate(view, url) { export async function getPreviewStatus({ org, site, pathname }) { const path = `/${org}/${site}${pathname}`; try { - const json = await aemAdmin(path, 'status', 'GET'); - if (!json) return null; + const { status } = await getNx2Api(); + const resp = await status.get(path); + if (!resp.ok) return null; + const json = await resp.json(); return json.preview?.status === 200; } catch { return null; diff --git a/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js b/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js index 355971db9..3cb5a7bde 100644 --- a/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js +++ b/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js @@ -5,11 +5,13 @@ setNx('/test/fixtures/nx', { hostname: 'example.com' }); let getBlockVariants; let extensionToPanelView; +let getPreviewStatus; before(async () => { const mod = await import('../../../../../blocks/canvas/ew-panel-extensions/helpers.js'); getBlockVariants = mod.getBlockVariants; extensionToPanelView = mod.extensionToPanelView; + getPreviewStatus = mod.getPreviewStatus; }); describe('EW panel helpers transformBlock', () => { @@ -208,3 +210,40 @@ describe('extensionToPanelView', () => { expect(view.openModal).to.be.undefined; }); }); + +// getPreviewStatus now goes through getNx2Api's isHlx6-aware status.get, not the legacy +// admin.hlx.page-only aemAdmin() helper. A real Response keeps isHlx6's ping (which fires +// before the actual status call) safe regardless of route. +describe('getPreviewStatus', () => { + let savedFetch; + + beforeEach(() => { savedFetch = window.fetch; }); + afterEach(() => { + window.fetch = savedFetch; + window.localStorage.removeItem('hlx6-upgrade'); + }); + + it('returns true when preview status is 200', async () => { + window.fetch = () => Promise.resolve(new Response( + JSON.stringify({ preview: { status: 200 } }), + { status: 200 }, + )); + const result = await getPreviewStatus({ org: 'pstatusorg', site: 'pstatussite', pathname: '/p' }); + expect(result).to.be.true; + }); + + it('returns false when preview status is not 200', async () => { + window.fetch = () => Promise.resolve(new Response( + JSON.stringify({ preview: { status: 404 } }), + { status: 200 }, + )); + const result = await getPreviewStatus({ org: 'pstatusorg2', site: 'pstatussite2', pathname: '/p' }); + expect(result).to.be.false; + }); + + it('returns null when the status call fails', async () => { + window.fetch = () => Promise.resolve(new Response('{}', { status: 500 })); + const result = await getPreviewStatus({ org: 'pstatusorg3', site: 'pstatussite3', pathname: '/p' }); + expect(result).to.equal(null); + }); +}); From 28978589874fd8629718d2cbe45d040bacd1ab94 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 18 Aug 2026 11:52:14 +0200 Subject: [PATCH 15/19] test: refuse an image over the upload limit red: the size guard and its toast do not exist yet --- .../fixtures/nx2/blocks/shared/toast/toast.js | 6 ++ .../prose-plugins/base64-upload.test.js | 93 +++++++++++++++++++ .../prose-plugins/upload.test.js | 30 ++++++ .../ew-editor-wysiwyg/image-upload.test.js | 22 +++++ .../blocks/canvas/utils/image-upload.test.js | 50 ++++++++++ 5 files changed, 201 insertions(+) create mode 100644 test/fixtures/nx2/blocks/shared/toast/toast.js create mode 100644 test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js create mode 100644 test/unit/blocks/canvas/utils/image-upload.test.js diff --git a/test/fixtures/nx2/blocks/shared/toast/toast.js b/test/fixtures/nx2/blocks/shared/toast/toast.js new file mode 100644 index 000000000..2c4830a9e --- /dev/null +++ b/test/fixtures/nx2/blocks/shared/toast/toast.js @@ -0,0 +1,6 @@ +// Test fixture mirroring nx2/blocks/shared/toast/toast.js. +export const toasts = []; + +export function showToast(opts) { + toasts.push(opts); +} diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js new file mode 100644 index 000000000..3ebace2df --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js @@ -0,0 +1,93 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../../scripts/utils.js'; +import { createTestEditor, destroyEditor } from '../../../edit/prose/test-helpers.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let base64Uploader; +let MAX_IMAGE_BYTES; +let toasts; + +const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); }); + +before(async () => { + ({ default: base64Uploader } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js')); + ({ MAX_IMAGE_BYTES } = await import('../../../../../../blocks/canvas/utils/image-upload.js')); + ({ toasts } = await import('../../../../../fixtures/nx2/blocks/shared/toast/toast.js')); +}); + +function stubStore() { + const saved = window.fetch; + const calls = []; + window.fetch = async (url, opts) => { + const href = typeof url === 'string' ? url : url.url; + calls.push({ url: href, opts }); + if (href.includes('/ping/')) { + return new Response('', { status: 200, headers: { 'x-api-upgrade-available': 'true' } }); + } + return new Response(JSON.stringify({ source: { contentUrl: './media_abc.png' } }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); + }; + return { calls, restore: () => { window.fetch = saved; } }; +} + +afterEach(() => { + window.localStorage.removeItem('hlx6-upgrade'); +}); + +describe('base64Uploader', () => { + let editor; + + const pluginFor = (sourceUrl) => base64Uploader({ + getSourceUrl: () => sourceUrl, + getEditorView: () => editor.view, + }); + + beforeEach(async () => { + editor = await createTestEditor(); + await nextFrame(); + toasts.length = 0; + }); + + afterEach(() => { + destroyEditor(editor); + }); + + it('drops a pasted image over the upload limit', async () => { + const { calls, restore } = stubStore(); + try { + // base64 inflates by 4/3, so this decodes to just over the limit + const src = `data:image/png;base64,${'A'.repeat(Math.ceil((MAX_IMAGE_BYTES + 1) / 3) * 4)}`; + const plugin = pluginFor('https://api.aem.live/pasteorg/sites/pastesite/source/doc.html'); + const html = plugin.props.transformPastedHTML(`

`); + await nextFrame(); + + expect(html).to.not.contain('data:image'); + expect(html).to.not.contain(' c.opts?.method === 'POST')).to.have.length(0); + expect(toasts).to.have.length(1); + expect(toasts[0].text).to.contain('Image upload failed'); + expect(toasts[0].text).to.contain('4.5 MB or under'); + } finally { + restore(); + } + }); + + it('uploads a pasted image inside the limit', async () => { + const { calls, restore } = stubStore(); + try { + const src = 'data:image/png;base64,iVBORw0KGgo='; + const plugin = pluginFor('https://api.aem.live/pasteok/sites/pasteok/source/doc.html'); + const html = plugin.props.transformPastedHTML(`

`); + await nextFrame(); + + expect(html).to.contain('/blocks/edit/img/fpo.svg'); + expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(1); + expect(toasts).to.have.length(0); + } finally { + restore(); + } + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js index 808882bac..b89eb7c3a 100644 --- a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js @@ -6,12 +6,16 @@ setNx('/test/fixtures/nx', { hostname: 'example.com' }); let getSourceUploadContext; let uploadImageFile; +let MAX_IMAGE_BYTES; +let toasts; const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); }); before(async () => { ({ getSourceUploadContext } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js')); ({ uploadImageFile } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js')); + ({ MAX_IMAGE_BYTES } = await import('../../../../../../blocks/canvas/utils/image-upload.js')); + ({ toasts } = await import('../../../../../fixtures/nx2/blocks/shared/toast/toast.js')); }); // isHlx6 memoizes its answer per site, so each case needs its own org/site @@ -136,4 +140,30 @@ describe('uploadImageFile', () => { restore(); } }); + + it('refuses an image over the upload limit', async () => { + const { calls, restore } = stubStore({ upgraded: true }); + toasts.length = 0; + try { + const big = new File( + [new Uint8Array(MAX_IMAGE_BYTES + 1)], + 'big.png', + { type: 'image/png' }, + ); + await uploadImageFile(editor.view, big, { parent: '/bigorg/bigsite', name: 'doc' }); + await nextFrame(); + + expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(0); + let images = 0; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'image') images += 1; + }); + expect(images, 'the fpo was left in the document').to.equal(0); + expect(toasts).to.have.length(1); + expect(toasts[0].text).to.contain('Image upload failed'); + expect(toasts[0].text).to.contain('4.5 MB or under'); + } finally { + restore(); + } + }); }); diff --git a/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js b/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js index 8b308cc34..447aa9edc 100644 --- a/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js +++ b/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js @@ -5,11 +5,15 @@ import { createTestEditor, destroyEditor } from '../../edit/prose/test-helpers.j setNx('/test/fixtures/nx', { hostname: 'example.com' }); let handleImageReplace; +let MAX_IMAGE_BYTES; +let toasts; const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); }); before(async () => { ({ handleImageReplace } = await import('../../../../../blocks/canvas/ew-editor-wysiwyg/utils/image.js')); + ({ MAX_IMAGE_BYTES } = await import('../../../../../blocks/canvas/utils/image-upload.js')); + ({ toasts } = await import('../../../../fixtures/nx2/blocks/shared/toast/toast.js')); }); function stubStore({ upgraded, contentUrl = './media_abc.png' }) { @@ -124,4 +128,22 @@ describe('handleImageReplace', () => { window.fetch = saved; } }); + + it('refuses an image over the upload limit', async () => { + const { calls, restore } = stubStore({ upgraded: true }); + const { ctx, posted } = ctxFor('wysbig', 'wysbig'); + // base64 inflates by 4/3, so this decodes to one byte over the limit + const big = `data:image/png;base64,${'A'.repeat(Math.ceil((MAX_IMAGE_BYTES + 1) / 3) * 4)}`; + toasts.length = 0; + try { + await handleImageReplace({ imageData: big, fileName: 'big.png', originalSrc: '/old.png' }, ctx); + + expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(0); + expect(posted.at(-1).payload.error).to.contain('too large'); + expect(toasts).to.have.length(1); + expect(toasts[0].text).to.contain('4.5 MB or under'); + } finally { + restore(); + } + }); }); diff --git a/test/unit/blocks/canvas/utils/image-upload.test.js b/test/unit/blocks/canvas/utils/image-upload.test.js new file mode 100644 index 000000000..2ca31eb03 --- /dev/null +++ b/test/unit/blocks/canvas/utils/image-upload.test.js @@ -0,0 +1,50 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let MAX_IMAGE_BYTES; +let isImageTooLarge; +let dataUrlByteLength; +let showImageTooLarge; +let toasts; + +before(async () => { + ({ + MAX_IMAGE_BYTES, isImageTooLarge, dataUrlByteLength, showImageTooLarge, + } = await import('../../../../../blocks/canvas/utils/image-upload.js')); + ({ toasts } = await import('../../../../fixtures/nx2/blocks/shared/toast/toast.js')); +}); + +beforeEach(() => { + toasts.length = 0; +}); + +describe('image upload limit', () => { + it('caps an upload below the api service request limit', () => { + // the AWS edge answers 413 above 4,717,360 bytes, measured 2026-08-18 + expect(MAX_IMAGE_BYTES).to.be.below(4717360); + }); + + it('takes a file at the limit and refuses the byte above it', () => { + expect(isImageTooLarge(MAX_IMAGE_BYTES)).to.equal(false); + expect(isImageTooLarge(MAX_IMAGE_BYTES + 1)).to.equal(true); + expect(isImageTooLarge(0)).to.equal(false); + }); + + it('reads the decoded length of a data url', () => { + expect(dataUrlByteLength('data:image/png;base64,AAAA')).to.equal(3); + expect(dataUrlByteLength('data:image/png;base64,AAA=')).to.equal(2); + expect(dataUrlByteLength('data:image/png;base64,AA==')).to.equal(1); + expect(dataUrlByteLength('not a data url')).to.equal(0); + }); +}); + +describe('showImageTooLarge', () => { + it('names the failure and the limit', async () => { + await showImageTooLarge(); + expect(toasts).to.have.length(1); + expect(toasts[0].variant).to.equal('error'); + expect(toasts[0].text).to.equal('Image upload failed\nImage size must be 4.5 MB or under'); + }); +}); From cd558cc9ff810bf77520a39403c505418be41c4b Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 18 Aug 2026 11:54:56 +0200 Subject: [PATCH 16/19] fix(canvas): fail an oversized image upload with a toast the api service 413s above ~4.5mb and that 413 has no cors header, so the browser saw a network error and the fpo sat on loading --- .../prose-plugins/base64Uploader.js | 6 +++++ .../ew-editor-doc/prose-plugins/imageDrop.js | 5 ++++ .../canvas/ew-editor-wysiwyg/utils/image.js | 10 +++++++ blocks/canvas/utils/image-upload.js | 27 +++++++++++++++++++ .../fixtures/nx2/blocks/shared/toast/toast.js | 8 ++++-- .../prose-plugins/base64-upload.test.js | 13 +++++++-- .../blocks/canvas/utils/image-upload.test.js | 4 +-- 7 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 blocks/canvas/utils/image-upload.js diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js index 5c068b4d2..7b88a9c1a 100644 --- a/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js +++ b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js @@ -1,6 +1,7 @@ import { Plugin } from 'da-y-wrapper'; import { getNx2Api } from '../../../../scripts/utils.js'; import { getSourceUploadContext } from './sourceUploadContext.js'; +import { dataUrlByteLength, isImageTooLarge, showImageTooLarge } from '../../utils/image-upload.js'; const FPO_IMG_URL = '/blocks/edit/img/fpo.svg'; @@ -56,6 +57,11 @@ export default function base64Uploader({ getSourceUrl, getEditorView }) { dataImgs.forEach((img) => { const src = img.getAttribute('src'); + if (isImageTooLarge(dataUrlByteLength(src))) { + img.remove(); + showImageTooLarge(); + return; + } let ext = src.replace('data:image/', '').split(';base64')[0]; if (ext === 'jpeg') ext = 'jpg'; const path = `${details.parent}/.${details.name}/wp${makeHash(src)}.${ext}`; diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js index fe9320acd..c501b5a2b 100644 --- a/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js +++ b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js @@ -1,12 +1,17 @@ import { Plugin, TextSelection } from 'da-y-wrapper'; import { getNx2Api } from '../../../../scripts/utils.js'; import { getSourceUploadContext } from './sourceUploadContext.js'; +import { isImageTooLarge, showImageTooLarge } from '../../utils/image-upload.js'; const FPO_IMG_URL = '/blocks/edit/img/fpo.svg'; export const SUPPORTED_IMAGE_FILES = ['image/svg+xml', 'image/png', 'image/jpeg', 'image/gif']; export async function uploadImageFile(view, file, details) { if (!SUPPORTED_IMAGE_FILES.some((type) => type === file.type)) return; + if (isImageTooLarge(file.size)) { + await showImageTooLarge(); + return; + } const { schema } = view.state; const fpo = schema.nodes.image.create({ src: FPO_IMG_URL, style: 'width: 180px' }); diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/image.js b/blocks/canvas/ew-editor-wysiwyg/utils/image.js index e46e0aa99..22afa57f5 100644 --- a/blocks/canvas/ew-editor-wysiwyg/utils/image.js +++ b/blocks/canvas/ew-editor-wysiwyg/utils/image.js @@ -1,5 +1,6 @@ import { getNx2Api } from '../../../../scripts/utils.js'; import { MESSAGE_TYPES } from '../../utils/quick-edit-messages.js'; +import { dataUrlByteLength, isImageTooLarge, showImageTooLarge } from '../../utils/image-upload.js'; function updateImageInDocument(view, originalSrc, newSrc) { if (!view) return false; @@ -60,6 +61,15 @@ export async function handleImageReplace({ imageData, fileName, originalSrc }, c ctx.suppressRerender = true; try { + if (isImageTooLarge(dataUrlByteLength(imageData))) { + await showImageTooLarge(); + ctx.port.postMessage({ + type: MESSAGE_TYPES.IMAGE_REPLACE, + payload: { error: 'Image is too large', originalSrc }, + }); + return; + } + const blob = dataUrlToBlob(imageData); const pageName = getPageName(ctx.path); diff --git a/blocks/canvas/utils/image-upload.js b/blocks/canvas/utils/image-upload.js new file mode 100644 index 000000000..7fd59b0e8 --- /dev/null +++ b/blocks/canvas/utils/image-upload.js @@ -0,0 +1,27 @@ +import { getNx2 } from '../../../scripts/utils.js'; + +// The api service runs on Lambda, which caps a request at 6 MiB once base64 has +// inflated the body. Measured 2026-08-18, the AWS edge answers 413 from 4,717,360 +// bytes up, and that 413 carries no CORS header, so the browser reads it as a +// network failure with no status. The size is checked before the request instead. +export const MAX_IMAGE_BYTES = 4500000; +const MAX_IMAGE_LABEL = '4.5 MB'; + +export function isImageTooLarge(bytes) { + return bytes > MAX_IMAGE_BYTES; +} + +export function dataUrlByteLength(dataUrl) { + const base64 = dataUrl?.split(';base64,')[1]; + if (!base64) return 0; + const padding = (base64.endsWith('==') && 2) || (base64.endsWith('=') && 1) || 0; + return Math.floor(base64.length / 4) * 3 - padding; +} + +export async function showImageTooLarge() { + const { showToast } = await import(`${getNx2()}/blocks/shared/toast/toast.js`); + showToast({ + text: `Image upload failed\nImage size must be ${MAX_IMAGE_LABEL} or under`, + variant: 'error', + }); +} diff --git a/test/fixtures/nx2/blocks/shared/toast/toast.js b/test/fixtures/nx2/blocks/shared/toast/toast.js index 2c4830a9e..2f4c31cc3 100644 --- a/test/fixtures/nx2/blocks/shared/toast/toast.js +++ b/test/fixtures/nx2/blocks/shared/toast/toast.js @@ -1,6 +1,10 @@ // Test fixture mirroring nx2/blocks/shared/toast/toast.js. -export const toasts = []; +// The array is parked on window because the import-maps plugin can load this +// module twice, once per specifier shape, and both copies have to record here. +window.daTestToasts ??= []; + +export const toasts = window.daTestToasts; export function showToast(opts) { - toasts.push(opts); + window.daTestToasts.push(opts); } diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js index 3ebace2df..0038d7310 100644 --- a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js @@ -10,6 +10,15 @@ let toasts; const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); }); +// the paste handler is synchronous and leaves the upload running behind it +async function until(done, tries = 50) { + for (let i = 0; i < tries; i += 1) { + if (done()) return; + // eslint-disable-next-line no-await-in-loop + await nextFrame(); + } +} + before(async () => { ({ default: base64Uploader } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js')); ({ MAX_IMAGE_BYTES } = await import('../../../../../../blocks/canvas/utils/image-upload.js')); @@ -62,7 +71,7 @@ describe('base64Uploader', () => { const src = `data:image/png;base64,${'A'.repeat(Math.ceil((MAX_IMAGE_BYTES + 1) / 3) * 4)}`; const plugin = pluginFor('https://api.aem.live/pasteorg/sites/pastesite/source/doc.html'); const html = plugin.props.transformPastedHTML(`

`); - await nextFrame(); + await until(() => toasts.length); expect(html).to.not.contain('data:image'); expect(html).to.not.contain(' { const src = 'data:image/png;base64,iVBORw0KGgo='; const plugin = pluginFor('https://api.aem.live/pasteok/sites/pasteok/source/doc.html'); const html = plugin.props.transformPastedHTML(`

`); - await nextFrame(); + await until(() => calls.some((c) => c.opts?.method === 'POST')); expect(html).to.contain('/blocks/edit/img/fpo.svg'); expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(1); diff --git a/test/unit/blocks/canvas/utils/image-upload.test.js b/test/unit/blocks/canvas/utils/image-upload.test.js index 2ca31eb03..d73734013 100644 --- a/test/unit/blocks/canvas/utils/image-upload.test.js +++ b/test/unit/blocks/canvas/utils/image-upload.test.js @@ -10,9 +10,7 @@ let showImageTooLarge; let toasts; before(async () => { - ({ - MAX_IMAGE_BYTES, isImageTooLarge, dataUrlByteLength, showImageTooLarge, - } = await import('../../../../../blocks/canvas/utils/image-upload.js')); + ({ MAX_IMAGE_BYTES, isImageTooLarge, dataUrlByteLength, showImageTooLarge } = await import('../../../../../blocks/canvas/utils/image-upload.js')); ({ toasts } = await import('../../../../fixtures/nx2/blocks/shared/toast/toast.js')); }); From 5f44f113f39734f2ee57dd6430ad9f4243880af8 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 18 Aug 2026 12:02:23 +0200 Subject: [PATCH 17/19] fix(canvas): apply the image size limit only on the source bus da-admin took a 120mb body in the same probe, so the cap is the api service's and a legacy site keeps taking large images --- .../prose-plugins/base64Uploader.js | 25 ++++-- .../ew-editor-doc/prose-plugins/imageDrop.js | 7 +- .../canvas/ew-editor-wysiwyg/utils/image.js | 6 +- blocks/canvas/utils/image-upload.js | 14 ++- .../prose-plugins/base64-upload.test.js | 90 ++++++++++++++----- .../prose-plugins/upload.test.js | 19 ++++ .../blocks/canvas/utils/image-upload.test.js | 66 +++++++++++++- 7 files changed, 185 insertions(+), 42 deletions(-) diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js index 7b88a9c1a..7d84c8758 100644 --- a/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js +++ b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js @@ -1,7 +1,7 @@ import { Plugin } from 'da-y-wrapper'; import { getNx2Api } from '../../../../scripts/utils.js'; import { getSourceUploadContext } from './sourceUploadContext.js'; -import { dataUrlByteLength, isImageTooLarge, showImageTooLarge } from '../../utils/image-upload.js'; +import { dataUrlByteLength, refuseOversizedImage } from '../../utils/image-upload.js'; const FPO_IMG_URL = '/blocks/edit/img/fpo.svg'; @@ -13,7 +13,21 @@ function makeHash(string) { } // the media bus is content addressed, so the src is only known from the response -export async function uploadBase64Image(view, { src, path, fpoSrc }) { +function removeFpo(view, fpoSrc) { + view.state.doc.descendants((node, pos) => { + if (node.type.name === 'image' && node.attrs.src === fpoSrc) { + view.dispatch(view.state.tr.delete(pos, pos + node.nodeSize)); + return false; + } + return true; + }); +} + +export async function uploadBase64Image(view, { src, path, fpoSrc, parent }) { + if (await refuseOversizedImage(dataUrlByteLength(src), parent)) { + removeFpo(view, fpoSrc); + return; + } const resp = await fetch(src); const blob = await resp.blob(); const { source } = await getNx2Api(); @@ -57,11 +71,6 @@ export default function base64Uploader({ getSourceUrl, getEditorView }) { dataImgs.forEach((img) => { const src = img.getAttribute('src'); - if (isImageTooLarge(dataUrlByteLength(src))) { - img.remove(); - showImageTooLarge(); - return; - } let ext = src.replace('data:image/', '').split(';base64')[0]; if (ext === 'jpeg') ext = 'jpg'; const path = `${details.parent}/.${details.name}/wp${makeHash(src)}.${ext}`; @@ -69,7 +78,7 @@ export default function base64Uploader({ getSourceUrl, getEditorView }) { img.setAttribute('src', fpoSrc); const view = getEditorView(); - if (view) uploadBase64Image(view, { src, path, fpoSrc }); + if (view) uploadBase64Image(view, { src, path, fpoSrc, parent: details.parent }); }); const serializer = new XMLSerializer(); diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js index c501b5a2b..d63a81734 100644 --- a/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js +++ b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js @@ -1,17 +1,14 @@ import { Plugin, TextSelection } from 'da-y-wrapper'; import { getNx2Api } from '../../../../scripts/utils.js'; import { getSourceUploadContext } from './sourceUploadContext.js'; -import { isImageTooLarge, showImageTooLarge } from '../../utils/image-upload.js'; +import { refuseOversizedImage } from '../../utils/image-upload.js'; const FPO_IMG_URL = '/blocks/edit/img/fpo.svg'; export const SUPPORTED_IMAGE_FILES = ['image/svg+xml', 'image/png', 'image/jpeg', 'image/gif']; export async function uploadImageFile(view, file, details) { if (!SUPPORTED_IMAGE_FILES.some((type) => type === file.type)) return; - if (isImageTooLarge(file.size)) { - await showImageTooLarge(); - return; - } + if (await refuseOversizedImage(file.size, details.parent)) return; const { schema } = view.state; const fpo = schema.nodes.image.create({ src: FPO_IMG_URL, style: 'width: 180px' }); diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/image.js b/blocks/canvas/ew-editor-wysiwyg/utils/image.js index 22afa57f5..2e07649c5 100644 --- a/blocks/canvas/ew-editor-wysiwyg/utils/image.js +++ b/blocks/canvas/ew-editor-wysiwyg/utils/image.js @@ -1,6 +1,6 @@ import { getNx2Api } from '../../../../scripts/utils.js'; import { MESSAGE_TYPES } from '../../utils/quick-edit-messages.js'; -import { dataUrlByteLength, isImageTooLarge, showImageTooLarge } from '../../utils/image-upload.js'; +import { dataUrlByteLength, refuseOversizedImage } from '../../utils/image-upload.js'; function updateImageInDocument(view, originalSrc, newSrc) { if (!view) return false; @@ -61,8 +61,8 @@ export async function handleImageReplace({ imageData, fileName, originalSrc }, c ctx.suppressRerender = true; try { - if (isImageTooLarge(dataUrlByteLength(imageData))) { - await showImageTooLarge(); + const sitePath = `/${ctx.owner}/${ctx.repo}`; + if (await refuseOversizedImage(dataUrlByteLength(imageData), sitePath)) { ctx.port.postMessage({ type: MESSAGE_TYPES.IMAGE_REPLACE, payload: { error: 'Image is too large', originalSrc }, diff --git a/blocks/canvas/utils/image-upload.js b/blocks/canvas/utils/image-upload.js index 7fd59b0e8..239a829d9 100644 --- a/blocks/canvas/utils/image-upload.js +++ b/blocks/canvas/utils/image-upload.js @@ -1,9 +1,10 @@ -import { getNx2 } from '../../../scripts/utils.js'; +import { getNx2, getNx2Api } from '../../../scripts/utils.js'; // The api service runs on Lambda, which caps a request at 6 MiB once base64 has // inflated the body. Measured 2026-08-18, the AWS edge answers 413 from 4,717,360 // bytes up, and that 413 carries no CORS header, so the browser reads it as a // network failure with no status. The size is checked before the request instead. +// da-admin took a 120 MB body in the same probe, so the cap is the source bus's. export const MAX_IMAGE_BYTES = 4500000; const MAX_IMAGE_LABEL = '4.5 MB'; @@ -25,3 +26,14 @@ export async function showImageTooLarge() { variant: 'error', }); } + +// `parentPath` is the document's folder, `/org/site/dir`. +export async function refuseOversizedImage(bytes, parentPath) { + if (!isImageTooLarge(bytes)) return false; + const [, org, site] = (parentPath ?? '').split('/'); + if (!org || !site) return false; + const { isHlx6 } = await getNx2Api(); + if (!await isHlx6(org, site)) return false; + await showImageTooLarge(); + return true; +} diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js index 0038d7310..802570feb 100644 --- a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js @@ -5,6 +5,7 @@ import { createTestEditor, destroyEditor } from '../../../edit/prose/test-helper setNx('/test/fixtures/nx', { hostname: 'example.com' }); let base64Uploader; +let uploadBase64Image; let MAX_IMAGE_BYTES; let toasts; @@ -20,20 +21,25 @@ async function until(done, tries = 50) { } before(async () => { - ({ default: base64Uploader } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js')); + ({ default: base64Uploader, uploadBase64Image } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js')); ({ MAX_IMAGE_BYTES } = await import('../../../../../../blocks/canvas/utils/image-upload.js')); ({ toasts } = await import('../../../../../fixtures/nx2/blocks/shared/toast/toast.js')); }); -function stubStore() { +// isHlx6 memoizes its answer per site, so each case needs its own org/site +function stubStore({ upgraded }) { const saved = window.fetch; const calls = []; window.fetch = async (url, opts) => { const href = typeof url === 'string' ? url : url.url; calls.push({ url: href, opts }); if (href.includes('/ping/')) { - return new Response('', { status: 200, headers: { 'x-api-upgrade-available': 'true' } }); + return new Response('', { + status: 200, + headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {}, + }); } + if (href.startsWith('data:')) return new Response(new Blob([new Uint8Array([1])])); return new Response(JSON.stringify({ source: { contentUrl: './media_abc.png' } }), { status: 201, headers: { 'content-type': 'application/json' }, @@ -49,10 +55,21 @@ afterEach(() => { describe('base64Uploader', () => { let editor; - const pluginFor = (sourceUrl) => base64Uploader({ - getSourceUrl: () => sourceUrl, - getEditorView: () => editor.view, - }); + const oversized = () => `data:image/png;base64,${'A'.repeat(Math.ceil((MAX_IMAGE_BYTES + 1) / 3) * 4)}`; + + const insertFpo = (fpoSrc) => { + const { schema } = editor.view.state; + const fpo = schema.nodes.image.create({ src: fpoSrc }); + editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(fpo)); + }; + + const imageSrcs = () => { + const srcs = []; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'image') srcs.push(node.attrs.src); + }); + return srcs; + }; beforeEach(async () => { editor = await createTestEditor(); @@ -64,18 +81,40 @@ describe('base64Uploader', () => { destroyEditor(editor); }); - it('drops a pasted image over the upload limit', async () => { - const { calls, restore } = stubStore(); + it('swaps a pasted data url for the fpo and uploads it', async () => { + const { calls, restore } = stubStore({ upgraded: true }); try { - // base64 inflates by 4/3, so this decodes to just over the limit - const src = `data:image/png;base64,${'A'.repeat(Math.ceil((MAX_IMAGE_BYTES + 1) / 3) * 4)}`; - const plugin = pluginFor('https://api.aem.live/pasteorg/sites/pastesite/source/doc.html'); - const html = plugin.props.transformPastedHTML(`

`); - await until(() => toasts.length); - - expect(html).to.not.contain('data:image'); - expect(html).to.not.contain(' 'https://api.aem.live/pasteok/sites/pasteok/source/doc.html', + getEditorView: () => editor.view, + }); + const html = plugin.props.transformPastedHTML('

'); + await until(() => calls.some((c) => c.opts?.method === 'POST')); + + expect(html).to.contain('/blocks/edit/img/fpo.svg'); + expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(1); + expect(toasts).to.have.length(0); + } finally { + restore(); + } + }); + + it('drops the fpo when the pasted image is over the upload limit', async () => { + const { calls, restore } = stubStore({ upgraded: true }); + const fpoSrc = '/blocks/edit/img/fpo.svg#1'; + try { + insertFpo(fpoSrc); + expect(imageSrcs()).to.deep.equal([fpoSrc]); + + await uploadBase64Image(editor.view, { + src: oversized(), + path: '/pastebig/pastebig/.doc/wp1.png', + fpoSrc, + parent: '/pastebig/pastebig', + }); + expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(0); + expect(imageSrcs(), 'the fpo was left in the document').to.deep.equal([]); expect(toasts).to.have.length(1); expect(toasts[0].text).to.contain('Image upload failed'); expect(toasts[0].text).to.contain('4.5 MB or under'); @@ -84,15 +123,18 @@ describe('base64Uploader', () => { } }); - it('uploads a pasted image inside the limit', async () => { - const { calls, restore } = stubStore(); + it('takes an oversized image on a legacy site, where the limit does not apply', async () => { + const { calls, restore } = stubStore({ upgraded: false }); + const fpoSrc = '/blocks/edit/img/fpo.svg#2'; try { - const src = 'data:image/png;base64,iVBORw0KGgo='; - const plugin = pluginFor('https://api.aem.live/pasteok/sites/pasteok/source/doc.html'); - const html = plugin.props.transformPastedHTML(`

`); - await until(() => calls.some((c) => c.opts?.method === 'POST')); + insertFpo(fpoSrc); + await uploadBase64Image(editor.view, { + src: oversized(), + path: '/pasteleg/pasteleg/.doc/wp2.png', + fpoSrc, + parent: '/pasteleg/pasteleg', + }); - expect(html).to.contain('/blocks/edit/img/fpo.svg'); expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(1); expect(toasts).to.have.length(0); } finally { diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js index b89eb7c3a..c1e84c07f 100644 --- a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js @@ -166,4 +166,23 @@ describe('uploadImageFile', () => { restore(); } }); + + it('takes an oversized image on a legacy site, where the limit does not apply', async () => { + const { calls, restore } = stubStore({ upgraded: false }); + toasts.length = 0; + try { + const big = new File( + [new Uint8Array(MAX_IMAGE_BYTES + 1)], + 'big.png', + { type: 'image/png' }, + ); + await uploadImageFile(editor.view, big, { parent: '/bigleg/bigleg', name: 'doc' }); + await nextFrame(); + + expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(1); + expect(toasts).to.have.length(0); + } finally { + restore(); + } + }); }); diff --git a/test/unit/blocks/canvas/utils/image-upload.test.js b/test/unit/blocks/canvas/utils/image-upload.test.js index d73734013..61f0e69c9 100644 --- a/test/unit/blocks/canvas/utils/image-upload.test.js +++ b/test/unit/blocks/canvas/utils/image-upload.test.js @@ -7,17 +7,38 @@ let MAX_IMAGE_BYTES; let isImageTooLarge; let dataUrlByteLength; let showImageTooLarge; +let refuseOversizedImage; let toasts; before(async () => { - ({ MAX_IMAGE_BYTES, isImageTooLarge, dataUrlByteLength, showImageTooLarge } = await import('../../../../../blocks/canvas/utils/image-upload.js')); + ({ + MAX_IMAGE_BYTES, + isImageTooLarge, + dataUrlByteLength, + showImageTooLarge, + refuseOversizedImage, + } = await import('../../../../../blocks/canvas/utils/image-upload.js')); ({ toasts } = await import('../../../../fixtures/nx2/blocks/shared/toast/toast.js')); }); +// isHlx6 memoizes its answer per site, so each case needs its own org/site +function stubPing(upgraded) { + const saved = window.fetch; + window.fetch = async () => new Response('', { + status: 200, + headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {}, + }); + return () => { window.fetch = saved; }; +} + beforeEach(() => { toasts.length = 0; }); +afterEach(() => { + window.localStorage.removeItem('hlx6-upgrade'); +}); + describe('image upload limit', () => { it('caps an upload below the api service request limit', () => { // the AWS edge answers 413 above 4,717,360 bytes, measured 2026-08-18 @@ -46,3 +67,46 @@ describe('showImageTooLarge', () => { expect(toasts[0].text).to.equal('Image upload failed\nImage size must be 4.5 MB or under'); }); }); + +describe('refuseOversizedImage', () => { + it('refuses an oversized image on a source bus site', async () => { + const restore = stubPing(true); + try { + expect(await refuseOversizedImage(MAX_IMAGE_BYTES + 1, '/refsb/refsb/dir')).to.equal(true); + expect(toasts).to.have.length(1); + } finally { + restore(); + } + }); + + it('takes an oversized image on a legacy site, where the limit does not apply', async () => { + const restore = stubPing(false); + try { + expect(await refuseOversizedImage(MAX_IMAGE_BYTES + 1, '/reflg/reflg/dir')).to.equal(false); + expect(toasts).to.have.length(0); + } finally { + restore(); + } + }); + + it('takes an image inside the limit without probing the store', async () => { + let probed = false; + const saved = window.fetch; + window.fetch = async () => { + probed = true; + return new Response('', { status: 200 }); + }; + try { + expect(await refuseOversizedImage(MAX_IMAGE_BYTES, '/refok/refok')).to.equal(false); + expect(probed, 'the store was probed for a file inside the limit').to.equal(false); + } finally { + window.fetch = saved; + } + }); + + it('takes an image it cannot place in a site', async () => { + expect(await refuseOversizedImage(MAX_IMAGE_BYTES + 1, '')).to.equal(false); + expect(await refuseOversizedImage(MAX_IMAGE_BYTES + 1, '/orgonly')).to.equal(false); + expect(toasts).to.have.length(0); + }); +}); From 8ea1ff1a92ba4ebd5bd101fed5d8837cb797b22f Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 18 Aug 2026 13:51:40 +0200 Subject: [PATCH 18/19] refactor(canvas): call buildSourceUrl directly, drop the ctx wrapper sourceUrlFromEditorCtx only passed ctx.path through; source.test.js already covers what its two tests did --- blocks/canvas/ew-editor-doc/utils/ctx.js | 6 +--- .../ew-editor-doc/utils/load-editor-doc.js | 5 ++- .../canvas/ew-editor-doc/utils/ctx.test.js | 33 +------------------ 3 files changed, 4 insertions(+), 40 deletions(-) diff --git a/blocks/canvas/ew-editor-doc/utils/ctx.js b/blocks/canvas/ew-editor-doc/utils/ctx.js index 6901a3416..0fb7d773c 100644 --- a/blocks/canvas/ew-editor-doc/utils/ctx.js +++ b/blocks/canvas/ew-editor-doc/utils/ctx.js @@ -1,8 +1,4 @@ -import { buildSourceUrl, normalizeSourcePath } from './source.js'; - -export function sourceUrlFromEditorCtx(ctx) { - return buildSourceUrl(ctx?.path); -} +import { normalizeSourcePath } from './source.js'; export function editorCtxHasOrgRepoPath(ctx) { const { org, repo, path } = ctx ?? {}; diff --git a/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js index ca9069913..4226f15a8 100644 --- a/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js @@ -1,5 +1,4 @@ -import { checkDoc } from './source.js'; -import { sourceUrlFromEditorCtx } from './ctx.js'; +import { buildSourceUrl, checkDoc } from './source.js'; import { initIms } from '../../../shared/utils.js'; export function sessionErrorFromResponse(resp) { @@ -23,7 +22,7 @@ export async function resolveEditorDocSession(ctx) { let sourceUrl; try { - sourceUrl = await sourceUrlFromEditorCtx(ctx); + sourceUrl = await buildSourceUrl(ctx?.path); } catch { return { ok: false, error: 'Could not reach the content store' }; } diff --git a/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js b/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js index 0dd28ff12..d2af95ffe 100644 --- a/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js +++ b/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js @@ -3,41 +3,10 @@ import { setNx } from '../../../../../../scripts/utils.js'; setNx('/test/fixtures/nx', { hostname: 'example.com' }); -let sourceUrlFromEditorCtx; let editorDocCanLoad; before(async () => { - ({ sourceUrlFromEditorCtx, editorDocCanLoad } = await import('../../../../../../blocks/canvas/ew-editor-doc/utils/ctx.js')); -}); - -function stubPing({ upgraded }) { - const saved = window.fetch; - window.fetch = async () => new Response('', { - status: 200, - headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {}, - }); - return () => { window.fetch = saved; }; -} - -afterEach(() => { - window.localStorage.removeItem('hlx6-upgrade'); -}); - -describe('sourceUrlFromEditorCtx', () => { - it('routes a source-bus document to api.aem.live', async () => { - const restore = stubPing({ upgraded: true }); - try { - const url = await sourceUrlFromEditorCtx({ org: 'ctxorg', repo: 'ctxsite', path: '/ctxorg/ctxsite/page' }); - expect(url).to.equal('https://api.aem.live/ctxorg/sites/ctxsite/source/page.html'); - } finally { - restore(); - } - }); - - it('answers null for a ctx with no path', async () => { - expect(await sourceUrlFromEditorCtx({ org: 'o', repo: 's' })).to.equal(null); - expect(await sourceUrlFromEditorCtx(null)).to.equal(null); - }); + ({ editorDocCanLoad } = await import('../../../../../../blocks/canvas/ew-editor-doc/utils/ctx.js')); }); describe('editorDocCanLoad', () => { From cf312ff83bc6dd6d24e07c58a562ee8d64abdc79 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 18 Aug 2026 14:17:14 +0200 Subject: [PATCH 19/19] fix(canvas): one line of toast text, and the exported error variant --- blocks/canvas/utils/image-upload.js | 6 +++--- test/fixtures/nx2/blocks/shared/toast/toast.js | 4 ++++ test/unit/blocks/canvas/utils/image-upload.test.js | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/blocks/canvas/utils/image-upload.js b/blocks/canvas/utils/image-upload.js index 239a829d9..2786957cc 100644 --- a/blocks/canvas/utils/image-upload.js +++ b/blocks/canvas/utils/image-upload.js @@ -20,10 +20,10 @@ export function dataUrlByteLength(dataUrl) { } export async function showImageTooLarge() { - const { showToast } = await import(`${getNx2()}/blocks/shared/toast/toast.js`); + const { showToast, VARIANT_ERROR } = await import(`${getNx2()}/blocks/shared/toast/toast.js`); showToast({ - text: `Image upload failed\nImage size must be ${MAX_IMAGE_LABEL} or under`, - variant: 'error', + text: `Image upload failed. Image size must be ${MAX_IMAGE_LABEL} or under`, + variant: VARIANT_ERROR, }); } diff --git a/test/fixtures/nx2/blocks/shared/toast/toast.js b/test/fixtures/nx2/blocks/shared/toast/toast.js index 2f4c31cc3..f24a97ad6 100644 --- a/test/fixtures/nx2/blocks/shared/toast/toast.js +++ b/test/fixtures/nx2/blocks/shared/toast/toast.js @@ -3,6 +3,10 @@ // module twice, once per specifier shape, and both copies have to record here. window.daTestToasts ??= []; +export const VARIANT_SUCCESS = 'success'; +export const VARIANT_ERROR = 'error'; +export const VARIANT_WARNING = 'warning'; + export const toasts = window.daTestToasts; export function showToast(opts) { diff --git a/test/unit/blocks/canvas/utils/image-upload.test.js b/test/unit/blocks/canvas/utils/image-upload.test.js index 61f0e69c9..13949b974 100644 --- a/test/unit/blocks/canvas/utils/image-upload.test.js +++ b/test/unit/blocks/canvas/utils/image-upload.test.js @@ -64,7 +64,7 @@ describe('showImageTooLarge', () => { await showImageTooLarge(); expect(toasts).to.have.length(1); expect(toasts[0].variant).to.equal('error'); - expect(toasts[0].text).to.equal('Image upload failed\nImage size must be 4.5 MB or under'); + expect(toasts[0].text).to.equal('Image upload failed. Image size must be 4.5 MB or under'); }); });