From 1fb2a6d9db6edd44680cdbccf3a4260d66361801 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Fri, 3 Jul 2026 09:00:46 +0200 Subject: [PATCH 1/3] fix(server): rewrite content.da.live image URLs to site preview domain content.da.live is not publicly reachable for rendering images during local dev, so img src referencing the org/site content store must be rewritten to the aem preview domain. Co-Authored-By: Claude Sonnet 5 --- src/server/HelixServer.js | 5 +++++ src/server/utils.js | 18 ++++++++++++++++++ test/server-utils.test.js | 34 ++++++++++++++++++++++++++++++++++ test/server.test.js | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/src/server/HelixServer.js b/src/server/HelixServer.js index c4b6ff76c..9b42be7a3 100644 --- a/src/server/HelixServer.js +++ b/src/server/HelixServer.js @@ -466,6 +466,11 @@ export class HelixServer extends BaseServer { ? `${contentFilePath.slice(0, -'.plain.html'.length)}.html` : contentFilePath; let htmlContent = await readFile(servedFilePath, 'utf-8'); + htmlContent = utils.rewriteDaContentImageUrls( + htmlContent, + this._project.org, + this._project.site, + ); if (isPlainFallback) { if (liveReload) { liveReload.registerFile(ctx.requestId, servedFilePath); diff --git a/src/server/utils.js b/src/server/utils.js index 36957db62..944c36e25 100644 --- a/src/server/utils.js +++ b/src/server/utils.js @@ -791,6 +791,24 @@ window.LiveReloadOptions = { return `${fullHead}
${content}
`; }, + /** + * Rewrites da.live content-store image URLs (`https://content.da.live/${org}/${site}/...`) + * to the site's preview domain, since content.da.live is not publicly reachable + * for rendering images during local dev. + * @param {string} html html content + * @param {string} org da.live org + * @param {string} site da.live site + * @returns {string} rewritten html + */ + rewriteDaContentImageUrls(html, org, site) { + if (!org || !site) { + return html; + } + const from = `https://content.da.live/${org}/${site}/`; + const to = `https://main--${site}--${org}.preview.da.live/`; + return html.split(from).join(to); + }, + /** * Extracts the innerHTML of the
element from a full HTML document. * Returns the original content unchanged if no
is found. diff --git a/test/server-utils.test.js b/test/server-utils.test.js index 9b9ef2f6c..5f339812d 100644 --- a/test/server-utils.test.js +++ b/test/server-utils.test.js @@ -348,4 +348,38 @@ describe('Utils Test', () => { assert.strictEqual(utils.extractMainContent(html), ' \n '); }); }); + + describe('rewriteDaContentImageUrls', () => { + it('rewrites content.da.live image src to the site preview domain', () => { + const html = ''; + assert.strictEqual( + utils.rewriteDaContentImageUrls(html, 'bar', 'foo'), + '', + ); + }); + + it('rewrites multiple occurrences', () => { + const html = '' + + ''; + assert.strictEqual( + utils.rewriteDaContentImageUrls(html, 'bar', 'foo'), + '' + + '', + ); + }); + + it('does not rewrite urls for a different org/site', () => { + const html = ''; + assert.strictEqual( + utils.rewriteDaContentImageUrls(html, 'bar', 'foo'), + html, + ); + }); + + it('returns html unchanged when org or site is missing', () => { + const html = ''; + assert.strictEqual(utils.rewriteDaContentImageUrls(html, '', 'foo'), html); + assert.strictEqual(utils.rewriteDaContentImageUrls(html, 'bar', ''), html); + }); + }); }); diff --git a/test/server.test.js b/test/server.test.js index 59eb758dc..e0867aa94 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -1271,6 +1271,43 @@ describe('Helix Server', () => { } }); + it('rewrites content.da.live image src to the site preview domain', async () => { + const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot); + await fse.ensureDir(path.join(cwd, CONTENT_DIR)); + await fse.writeFile( + path.join(cwd, CONTENT_DIR, 'index.html'), + '
', + ); + await fse.writeFile( + path.join(cwd, 'head.html'), + '', + ); + + nock('http://main--foo--bar.aem.page') + .get('/head.html') + .reply(200, '', { 'content-type': 'text/html' }) + .get('/metadata.json') + .reply(200, { data: [] }); + + const project = new HelixProject() + .withCwd(cwd) + .withProxyUrl('http://main--foo--bar.aem.page') + .withSite('foo') + .withOrg('bar') + .withHttpPort(0); + await project.init(); + try { + await project.start(); + const resp = await getFetch()(`http://127.0.0.1:${project.server.port}/index.html`); + assert.strictEqual(resp.status, 200); + const body = await resp.text(); + assert.ok(body.includes('src="https://main--foo--bar.preview.da.live/media_123.png"')); + assert.ok(!body.includes('content.da.live')); + } finally { + await project.stop(); + } + }); + it('falls through to proxy when file is not in content/', async () => { const cwd = await setupProject(path.join(__rootdir, 'test', 'fixtures', 'project'), testRoot); await fse.ensureDir(path.join(cwd, CONTENT_DIR)); From ac50ffbf9051e6f3a7f77d4e8864dc58836824c2 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Fri, 3 Jul 2026 10:12:34 +0200 Subject: [PATCH 2/3] fix(server): auth to da.live preview host for content.da.live images content.da.live is auth-gated for images, so local content/ pages need an IMS-authenticated cookie on the site's *.preview.da.live host to render them. Adds a client-side bootstrap that checks for an existing valid cookie/session first, and only then falls back to IMS login (via a dev-server-driven redirect, since the darkalley IMS client only allows its fixed :9898 callback as redirect_uri). Fixes #2752 Co-Authored-By: Claude Sonnet 5 --- .../src/da-content-auth.js | 108 ++++++++++++ src/content/da-auth.js | 47 ++++- src/server/HelixServer.js | 46 +++++ src/server/utils.js | 56 ++++++ test/content/da-auth.test.js | 20 +++ test/server-utils.test.js | 66 +++++++ test/server.test.js | 161 +++++++++++++++++- 7 files changed, 497 insertions(+), 7 deletions(-) create mode 100644 packages/browser-injectables/src/da-content-auth.js diff --git a/packages/browser-injectables/src/da-content-auth.js b/packages/browser-injectables/src/da-content-auth.js new file mode 100644 index 000000000..1ce5df9a4 --- /dev/null +++ b/packages/browser-injectables/src/da-content-auth.js @@ -0,0 +1,108 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * da.live content-image auth bootstrap. + * Only injected into pages that reference a *.preview.da.live host, since that host + * requires an IMS-authenticated cookie. First checks whether the browser already has + * a valid (non-expired) cookie for that host; only if not does it load IMS and either + * forward the access token to /gimme_cookie, or send the browser through the CLI's own + * /.aem/cli/da-login redirect (imslib's signIn() ignores this page's origin as the + * redirect_uri — the darkalley IMS client only allows its fixed :9898 callback). + * This script is self-contained and browser-compatible (no module system). + */ +(function iife() { + var cfg = window.DaContentAuthConfig; + if (!cfg || !cfg.previewOrigin || !cfg.clientId) { + return; + } + + // sendCookie() only ever runs after we've determined the browser had no valid + // preview cookie, meaning any preview-host images on this page already fired + // (and failed) before the cookie existed. Reload once so they're refetched with + // the new cookie attached — without this, the page looks broken until the user + // manually reloads. + function sendCookie(token) { + window.fetch(`${cfg.previewOrigin}/gimme_cookie`, { + method: 'GET', + credentials: 'include', + headers: { Authorization: `Bearer ${token}` }, + }).then(function onResponse(res) { + if (res.ok) { + window.location.reload(); + } + }).catch(function onError() { + // non-fatal: images will fail to load, page still renders + }); + } + + function redirectToLogin() { + var returnUrl = window.location.href.split('#')[0]; + window.location.href = `/.aem/cli/da-login?return=${encodeURIComponent(returnUrl)}`; + } + + function bootstrapIms() { + window.adobeid = { + client_id: cfg.clientId, + scope: cfg.scope, + environment: 'prod', + autoValidateToken: true, + useLocalStorage: true, + onReady: function onReady() { + var accessToken = window.adobeIMS.getAccessToken(); + if (accessToken) { + sendCookie(accessToken.token); + } else { + redirectToLogin(); + } + }, + onError: function onError() { + // non-fatal: images will fail to load, page still renders + }, + }; + var script = document.createElement('script'); + script.src = 'https://auth.services.adobe.com/imslib/imslib.min.js'; + document.head.appendChild(script); + } + + // A credentialed request to an actual gated asset fails (401/403) whenever there's + // no cookie yet or the existing one has expired — the server re-checks it every + // request, so this alone covers both cases without us tracking expiry ourselves. + // Probing the site root wouldn't work: it's often served regardless of auth, only + // the assets themselves are gated. + function hasValidCookie() { + return window.fetch(`${cfg.previewOrigin}${cfg.probePath || '/'}`, { + method: 'HEAD', + credentials: 'include', + cache: 'no-store', + }).then(function onResponse(res) { + return res.ok; + }).catch(function onError() { + return false; + }); + } + + // Returning from the /.aem/cli/da-login round trip: the access token is in the URL fragment. + var hashParams = new URLSearchParams(window.location.hash.replace(/^#/, '')); + var tokenFromRedirect = hashParams.get('access_token'); + if (tokenFromRedirect) { + window.history.replaceState(null, '', window.location.pathname + window.location.search); + sendCookie(tokenFromRedirect); + return; + } + + hasValidCookie().then(function onChecked(valid) { + if (!valid) { + bootstrapIms(); + } + }); +}()); diff --git a/src/content/da-auth.js b/src/content/da-auth.js index 10fdf4d02..83b304431 100644 --- a/src/content/da-auth.js +++ b/src/content/da-auth.js @@ -16,8 +16,11 @@ import open from 'open'; import { ensureGitIgnored } from './content-git.js'; const IMS_ORIGIN = 'https://ims-na1.adobelogin.com'; -const CLIENT_ID = 'darkalley'; -const SCOPE = 'ab.manage,AdobeID,gnav,openid,org.read,read_organizations,session,aem.frontend.all,additional_info.ownerOrg,additional_info.projectedProductContext,account_cluster.read'; +/** Shared with da-live's own IMS client (see da-live/scripts/scripts.js). */ +export const DA_IMS_CLIENT_ID = 'darkalley'; +export const DA_IMS_SCOPE = 'ab.manage,AdobeID,gnav,openid,org.read,read_organizations,session,aem.frontend.all,additional_info.ownerOrg,additional_info.projectedProductContext,account_cluster.read'; +const CLIENT_ID = DA_IMS_CLIENT_ID; +const SCOPE = DA_IMS_SCOPE; const CALLBACK_PORT = 9898; const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/callback`; @@ -70,11 +73,15 @@ function isTokenExpired(stored) { * IMS redirects to http://localhost:{port}/callback#access_token=TOKEN * The fragment never reaches the server, so /callback serves a tiny HTML page * that reads the fragment via JS and forwards the token to /token, then - * redirects the browser to https://tools.aem.live/cli/logged-in on success. + * redirects the browser on success — to `finalRedirectUrl` (with the token appended + * as a URL fragment) when given, otherwise to https://tools.aem.live/cli/logged-in. * + * @param {string} [finalRedirectUrl] where to send the browser after login, + * used by the `aem up` dev-server login flow to return to the page that + * triggered it. Omitted for the CLI's own `content clone`/`content push` login. * @returns {Promise<{token: string, expiresIn: number|null}>} */ -function waitForToken() { +function waitForToken(finalRedirectUrl) { return new Promise((resolve, reject) => { let timeout; const server = http.createServer((req, res) => { @@ -83,6 +90,9 @@ function waitForToken() { // Step 1: IMS lands here with the token in the fragment. // Serve a page that extracts it and calls /token. if (url.pathname === '/callback') { + const finalizeJs = finalRedirectUrl + ? "window.location.href = loggedInUrl + '#access_token=' + encodeURIComponent(token) + (expiresIn ? '&expires_in=' + encodeURIComponent(expiresIn) : '');" + : 'window.location.href = loggedInUrl;'; res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(`Logging in... ` + + ``; + return `${html.substring(0, index)}${script}${html.substring(index)}`; + }, + + /** + * Serves the da-content-auth bootstrap as a static JS file (referenced by + * {@link injectDaContentAuthScript}). + * @param {Express.Response} res response + */ + serveDaContentAuthScript(res) { + res.set('content-type', 'application/javascript'); + res.send(DA_CONTENT_AUTH_SCRIPT); + }, + /** * Extracts the innerHTML of the
element from a full HTML document. * Returns the original content unchanged if no
is found. diff --git a/test/content/da-auth.test.js b/test/content/da-auth.test.js index 3fb2a0254..e4fd640ac 100644 --- a/test/content/da-auth.test.js +++ b/test/content/da-auth.test.js @@ -208,3 +208,23 @@ describe('getValidToken', () => { assert.strictEqual(readPath, path.join(TEST_PROJECT_DIR, '.hlx', '.da-token.json')); }); }); + +describe('startDaLoginRedirect', () => { + it('returns the fixed :9898 callback as redirect_uri, regardless of the return url\'s own port', async () => { + // Mock http so no real socket is bound (the fire-and-forget callback server + // this starts is never asked for a token in this test). + const { startDaLoginRedirect } = await esmock('../../src/content/da-auth.js', { + 'node:http': { + default: { + createServer: () => ({ listen: () => {}, close: () => {}, on: () => {} }), + }, + }, + }); + const authUrl = startDaLoginRedirect('http://127.0.0.1:54321/index.html'); + const parsed = new URL(authUrl); + assert.strictEqual(parsed.origin, 'https://ims-na1.adobelogin.com'); + assert.strictEqual(parsed.searchParams.get('response_type'), 'token'); + assert.strictEqual(parsed.searchParams.get('client_id'), 'darkalley'); + assert.strictEqual(parsed.searchParams.get('redirect_uri'), 'http://localhost:9898/callback'); + }); +}); diff --git a/test/server-utils.test.js b/test/server-utils.test.js index 5f339812d..5e2e8f70d 100644 --- a/test/server-utils.test.js +++ b/test/server-utils.test.js @@ -382,4 +382,70 @@ describe('Utils Test', () => { assert.strictEqual(utils.rewriteDaContentImageUrls(html, 'bar', ''), html); }); }); + + describe('findDaPreviewProbePath', () => { + const previewOrigin = 'https://main--foo--bar.preview.da.live'; + + it('finds the path of an asset served from the preview origin', () => { + const html = ``; + assert.strictEqual(utils.findDaPreviewProbePath(html, previewOrigin), '/.index/photo-abc123.png'); + }); + + it('stops at the closing quote', () => { + const html = ``; + assert.strictEqual(utils.findDaPreviewProbePath(html, previewOrigin), '/.index/photo.png'); + }); + + it('defaults to / when no asset path is found', () => { + const html = '

no preview links here

'; + assert.strictEqual(utils.findDaPreviewProbePath(html, previewOrigin), '/'); + }); + }); + + describe('injectDaContentAuthScript', () => { + const options = { + previewOrigin: 'https://main--foo--bar.preview.da.live', + probePath: '/.index/photo-abc123.png', + clientId: 'darkalley', + scope: 'AdobeID,openid', + }; + + it('injects the config and auth bootstrap script before ', () => { + const html = 'Test'; + const out = utils.injectDaContentAuthScript(html, options); + assert.ok(out.includes(''; + const out = utils.injectDaContentAuthScript(html, options); + assert.ok(out.includes('