From 3300b1ee17633368f4746453ce46cda0c65b77e1 Mon Sep 17 00:00:00 2001 From: Tobias Bocanegra Date: Thu, 6 Aug 2026 13:45:40 +0200 Subject: [PATCH 1/5] feat: add media upload api --- nx2/utils/api.js | 111 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 87 insertions(+), 24 deletions(-) diff --git a/nx2/utils/api.js b/nx2/utils/api.js index d86450e7e..5b6c357e4 100644 --- a/nx2/utils/api.js +++ b/nx2/utils/api.js @@ -296,31 +296,88 @@ export const source = { return { ok: true, items, continuationToken: nextToken, permissions }; }), - save: withArgs(async ({ org, site, path, body }) => { - const hlx6 = await isHlx6(org, site); + save: withArgs(async (opts) => { + const { org, site } = opts; + if (await isHlx6(org, site)) { + return this._saveHlx6(opts); + } + return this._saveDA(opts); + }), + + _saveHlx6: withArgs(async ({ org, site, path, body }) => { const url = await getDaApiPath(SOURCE, org, site, path); - const opts = { method: 'POST' }; - const ext = Object.keys(TYPE_MAP).find((e) => path.toLowerCase().endsWith(e)); - if (hlx6) { - opts.body = body; - if (ext) opts.headers = { 'Content-Type': TYPE_MAP[ext] }; - const resp = await daFetch({ url, opts }); - // hlx6 source save returns an empty body, whereas DA returns - // { source: { contentUrl } }. Normalize the success case to that shape - // so callers can read source.contentUrl uniformly across hlx5/hlx6. - // contentUrl comes from the response's location header (resolved - // against the request url) since the server may write the source to a - // different canonical path than the one requested. - const location = resp.headers.get('location') || ''; - const sourceUrl = new URL(location, url).href; - return resp.ok ? withSourceJson(resp, sourceUrl) : resp; + const opts = { + method: 'POST', + body, + }; + const contentType = findContentType(path); + if (contentType) { + opts.headers = { 'Content-Type': contentType }; } + const resp = await daFetch({ url, opts }); + // hlx6 source save returns an empty body, whereas DA returns + // { source: { contentUrl } }. Normalize the success case to that shape + // so callers can read source.contentUrl uniformly across hlx5/hlx6. + // contentUrl comes from the response's location header (resolved + // against the request url) since the server may write the source to a + // different canonical path than the one requested. + const location = resp.headers.get('location') || ''; + const sourceUrl = new URL(location, url).href; + return resp.ok + ? adaptJsonResponse(resp, { source: { contentUrl: sourceUrl } }) + : resp; + }), + + _saveDA: withArgs(async ({ org, site, path, body }) => { + const url = await getDaApiPath(SOURCE, org, site, path); const formData = new FormData(); - formData.append('data', new Blob([body], { type: TYPE_MAP[ext] })); + formData.append('data', new Blob([body], { type: findContentType(path) })); + const opts = { + method: 'POST', + body: formData, + }; opts.body = formData; return daFetch({ url, opts }); }), + // special method to upload media. for hlx6, this will use the api service's '/media' route, + // for non hlx6 it will just use the normal source save for now. + uploadMedia: withArgs(async ({ org, site, path, body }) => { + const hlx6 = await isHlx6(org, site); + if (!hlx6) { + // fall back to original source store + return this._saveDA({ org, site, path, body }); + } + const url = `${AEM_API}/${org}/sites/${site}/media/${path}`; + const opts = { + method: 'POST', + body, + headers: { + 'content-type': findContentType(path) || 'application/octet-stream', + }, + }; + const resp = await daFetch({ url, opts }); + if (resp.ok) { + const json = await resp.json(); + // api returns: + // { + // uri, + // meta: { + // type: 'image/png', + // width: 640, + // height: 480, + // }, + return adaptJsonResponse(resp, { + source: { + contentUrl: json.uri, + }, + // exact use to be defined + meta: json.meta, + }); + } + return resp; + }), + // HEAD request — the value is in the response headers (doc-id, last-modified, etc.). getMetadata: withArgs(async ({ org, site, path }) => { const url = await getDaApiPath(SOURCE, org, site, path); @@ -616,6 +673,15 @@ const TYPE_MAP = { '.pdf': 'application/pdf', }; +/** + * finds the content type by path extension + * @param path + */ +function findContentType(path) { + const ext = Object.keys(TYPE_MAP).find((e) => path.toLowerCase().endsWith(e)); + return TYPE_MAP[ext]; +} + // DA-owned endpoints proxied between DA_ADMIN and AEM_API. async function getDaApiPath(api, org, site, path = '') { const hlx6 = await isHlx6(org, site); @@ -694,12 +760,9 @@ function normalizePath(path) { return path.startsWith('/') ? path : `/${path}`; } -// Shadow a Response's `json()` so it resolves to the DA-shaped -// `{ source: { contentUrl } }`. Used for hlx6 saves, whose body is empty — -// preserves the original ok/status/headers/permissions. -function withSourceJson(resp, contentUrl) { - resp.json = async () => ({ source: { contentUrl } }); - return resp; +// Create a new response with a different JSON response body +function adaptJsonResponse(resp, obj) { + return new Response(JSON.stringify(obj), resp); } function jsonOpts(method, payload) { From 0f44fd6f4913ad2898d5055167073333edb04c3a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 19:42:17 +0200 Subject: [PATCH 2/5] Update worklog --- WORKLOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/WORKLOG.md b/WORKLOG.md index 4961c5797..0abcbd1d9 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -1,5 +1,15 @@ # Worklog +## 2026-08-06 + +### nx2/utils/api.js — tests for `source.uploadMedia`, plus two bug fixes found while writing them + +Added test coverage for the new `source.uploadMedia({ org, site, path, body })` method (added in `3300b1ee`, "feat: add media upload api"): legacy delegation to `_saveDA` as FormData, the stage `content.da.live` → `stage-content.da.live` contentUrl rewrite, hlx6 POSTs to the AEM media route with the correct `content-type` header, `contentUrl` prefix-stripping against the site's `aem.page` origin, non-ok passthrough for both branches, and the `/org/site/path` string call form. 11 new tests in `test/nx2/utils/api.test.js`. + +Two bugs surfaced while writing the tests (both fixed, confirmed with the author): +1. The non-hlx6 branch fell through to the hlx6 media POST whenever `DA_ADMIN` wasn't exactly `'https://stage-admin.da.live'` — i.e. for any ordinary non-hlx6 site in most environments, `uploadMedia` made a second, unintended request to the hlx6-only endpoint after `_saveDA` had already completed. Fixed by returning after the `_saveDA` call unconditionally. +2. In this repo's test/dev env `DA_ADMIN` *is* `'https://stage-admin.da.live'`, so the stage-content rewrite branch always runs for non-hlx6 uploads. When the returned `contentUrl`'s host wasn't `content.da.live` (no rewrite needed), the code had already consumed the response body via `resp.json()` and then returned that same (now-drained) `Response` — any caller subsequently calling `resp.json()` would get a "body stream already read" error. Fixed by always returning `adaptJsonResponse(resp, json)` in that branch, rewritten or not, so callers get a fresh readable response either way. + ## 2026-07-30 ### nx2/utils/api.js — normalize hlx6 `source.save` response to `{ source: { contentUrl } }` (#631) From 3070fa631e81be49acb7fcebe7e09c5cace7bbb3 Mon Sep 17 00:00:00 2001 From: Tobias Bocanegra Date: Thu, 6 Aug 2026 19:42:52 +0200 Subject: [PATCH 3/5] test: add coverage for source.uploadMedia, fix two response-handling bugs Adds tests for the new source.uploadMedia API (legacy FormData delegation with the stage content.da.live -> stage-content.da.live rewrite, hlx6 media POST with content-type + contentUrl normalization, non-ok passthrough, and the path-string call form). Writing the tests surfaced two bugs in the uncommitted uploadMedia implementation, both fixed here: a fallthrough that made non-hlx6 uploads also hit the hlx6-only media endpoint, and a body-stream-already-read bug where a successful non-hlx6 response without a content.da.live host was returned after its body had already been consumed. --- nx2/utils/api.js | 34 ++++++++--- test/nx2/utils/api.test.js | 122 +++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 8 deletions(-) diff --git a/nx2/utils/api.js b/nx2/utils/api.js index 5b6c357e4..bdafd497e 100644 --- a/nx2/utils/api.js +++ b/nx2/utils/api.js @@ -82,7 +82,7 @@ export const config = { const resp = await daFetch({ url }); if (resp.ok) { const cfg = object2sheet(await resp.json()); - resp.json = () => cfg; + return adaptJsonResponse(resp, cfg); } return resp; } @@ -299,9 +299,11 @@ export const source = { save: withArgs(async (opts) => { const { org, site } = opts; if (await isHlx6(org, site)) { - return this._saveHlx6(opts); + // eslint-disable-next-line no-underscore-dangle + return source._saveHlx6(opts); } - return this._saveDA(opts); + // eslint-disable-next-line no-underscore-dangle + return source._saveDA(opts); }), _saveHlx6: withArgs(async ({ org, site, path, body }) => { @@ -346,9 +348,21 @@ export const source = { const hlx6 = await isHlx6(org, site); if (!hlx6) { // fall back to original source store - return this._saveDA({ org, site, path, body }); + // eslint-disable-next-line no-underscore-dangle + const resp = await source._saveDA({ org, site, path, body }); + if (resp.ok && DA_ADMIN === 'https://stage-admin.da.live') { + // special check for stage content. should be handled in stage da-admin ? + const json = await resp.json(); + const sourceUrl = new URL(json.source.contentUrl); + if (sourceUrl.host === 'content.da.live') { + sourceUrl.host = 'stage-content.da.live'; + json.source.contentUrl = sourceUrl.href; + } + return adaptJsonResponse(resp, json); + } + return resp; } - const url = `${AEM_API}/${org}/sites/${site}/media/${path}`; + const url = `${AEM_API}/${org}/sites/${site}/media${path}`; const opts = { method: 'POST', body, @@ -359,17 +373,21 @@ export const source = { const resp = await daFetch({ url, opts }); if (resp.ok) { const json = await resp.json(); - // api returns: // { - // uri, + // uri: 'https://main--site--org.aem.page/media_...., // meta: { // type: 'image/png', // width: 640, // height: 480, // }, + const pfx = `https://main--${site}--${org}.aem.page/`; + let contentUrl = json.uri; + if (contentUrl.startsWith(pfx)) { + contentUrl = `./${contentUrl.substring(pfx.length)}`; + } return adaptJsonResponse(resp, { source: { - contentUrl: json.uri, + contentUrl, }, // exact use to be defined meta: json.meta, diff --git a/test/nx2/utils/api.test.js b/test/nx2/utils/api.test.js index 3b766f464..4ef4a51d4 100644 --- a/test/nx2/utils/api.test.js +++ b/test/nx2/utils/api.test.js @@ -488,6 +488,128 @@ describe('api.js', () => { expect(resp.status).to.equal(403); }); + it('source.uploadMedia legacy delegates to _saveDA as FormData with no double-fetch', async () => { + restoreFetch(); + installFetch({ + body: JSON.stringify({ source: { contentUrl: 'https://stage-content.da.live/o/s/img.png' } }), + }); + const { org: o, site: s } = makeOrgSite(); + const data = new Blob(['binary'], { type: 'image/png' }); + await source.uploadMedia({ org: o, site: s, path: '/img.png', body: data }); + const last = lastCall(); + expect(last.method).to.equal('POST'); + expect(last.body).to.be.instanceof(FormData); + const stored = last.body.get('data'); + expect(stored).to.be.instanceof(Blob); + expect(stored.size).to.equal(data.size); + expect(calls.some((c) => c.url.includes('/media'))).to.equal(false); + }); + + it('source.uploadMedia legacy rewrites a content.da.live contentUrl to stage-content.da.live', async () => { + restoreFetch(); + installFetch({ + body: JSON.stringify({ source: { contentUrl: 'https://content.da.live/o/s/img.png' } }), + }); + const { org: o, site: s } = makeOrgSite(); + const resp = await source.uploadMedia({ org: o, site: s, path: '/img.png', body: new Blob(['x']) }); + expect(resp.ok).to.equal(true); + const json = await resp.json(); + expect(json.source.contentUrl).to.equal('https://stage-content.da.live/o/s/img.png'); + }); + + it('source.uploadMedia legacy leaves contentUrl unchanged when host is not content.da.live', async () => { + restoreFetch(); + installFetch({ + body: JSON.stringify({ source: { contentUrl: 'https://stage-content.da.live/o/s/img.png' } }), + }); + const { org: o, site: s } = makeOrgSite(); + const resp = await source.uploadMedia({ org: o, site: s, path: '/img.png', body: new Blob(['x']) }); + expect(resp.ok).to.equal(true); + const json = await resp.json(); + expect(json.source.contentUrl).to.equal('https://stage-content.da.live/o/s/img.png'); + }); + + it('source.uploadMedia legacy returns the response unmodified on failure', async () => { + restoreFetch(); + installFetch({ status: 500, body: '' }); + const { org: o, site: s } = makeOrgSite(); + const resp = await source.uploadMedia({ org: o, site: s, path: '/img.png', body: new Blob(['x']) }); + expect(resp.ok).to.equal(false); + expect(resp.status).to.equal(500); + expect(calls.some((c) => c.url.includes('/media'))).to.equal(false); + }); + + it('source.uploadMedia hlx6 POSTs to the media route with content-type header and raw body', async () => { + const { org: o, site: s } = makeOrgSite({ hlx6: true }); + restoreFetch(); + installFetch({ + body: JSON.stringify({ uri: `https://main--${s}--${o}.aem.page/media_1.png`, meta: {} }), + }); + const blob = new Blob(['binary'], { type: 'image/png' }); + await source.uploadMedia({ org: o, site: s, path: '/img.png', body: blob }); + const last = lastCall(); + expect(last.url).to.equal(`${AEM_API}/${o}/sites/${s}/media/img.png`); + expect(last.method).to.equal('POST'); + expect(last.headers['content-type']).to.equal('image/png'); + expect(last.body).to.equal(blob); + }); + + it('source.uploadMedia hlx6 falls back to application/octet-stream for unknown extensions', async () => { + const { org: o, site: s } = makeOrgSite({ hlx6: true }); + restoreFetch(); + installFetch({ + body: JSON.stringify({ uri: `https://main--${s}--${o}.aem.page/media_1`, meta: {} }), + }); + await source.uploadMedia({ org: o, site: s, path: '/file.xyz', body: new Blob(['x']) }); + expect(lastCall().headers['content-type']).to.equal('application/octet-stream'); + }); + + it('source.uploadMedia hlx6 normalizes contentUrl by stripping the site aem.page prefix', async () => { + restoreFetch(); + const { org: o, site: s } = makeOrgSite({ hlx6: true }); + installFetch({ + body: JSON.stringify({ + uri: `https://main--${s}--${o}.aem.page/media_123.png`, + meta: { type: 'image/png', width: 640, height: 480 }, + }), + }); + const resp = await source.uploadMedia({ org: o, site: s, path: '/img.png', body: new Blob(['x']) }); + const json = await resp.json(); + expect(json.source.contentUrl).to.equal('./media_123.png'); + expect(json.meta).to.deep.equal({ type: 'image/png', width: 640, height: 480 }); + }); + + it('source.uploadMedia hlx6 leaves contentUrl unchanged when uri does not match the site prefix', async () => { + restoreFetch(); + installFetch({ + body: JSON.stringify({ uri: 'https://cdn.example.com/media_999.png', meta: {} }), + }); + const { org: o, site: s } = makeOrgSite({ hlx6: true }); + const resp = await source.uploadMedia({ org: o, site: s, path: '/img.png', body: new Blob(['x']) }); + const json = await resp.json(); + expect(json.source.contentUrl).to.equal('https://cdn.example.com/media_999.png'); + }); + + it('source.uploadMedia hlx6 returns the raw response on non-ok status without parsing the body', async () => { + restoreFetch(); + installFetch({ status: 404, body: '' }); + const { org: o, site: s } = makeOrgSite({ hlx6: true }); + const resp = await source.uploadMedia({ org: o, site: s, path: '/img.png', body: new Blob(['x']) }); + expect(resp.ok).to.equal(false); + expect(resp.status).to.equal(404); + }); + + it('source.uploadMedia accepts a path string with extras', async () => { + const { org: o, site: s } = makeOrgSite({ hlx6: true }); + restoreFetch(); + installFetch({ + body: JSON.stringify({ uri: `https://main--${s}--${o}.aem.page/media_1.png`, meta: {} }), + }); + const blob = new Blob(['x'], { type: 'image/png' }); + await source.uploadMedia(`/${o}/${s}/img.png`, { body: blob }); + expect(lastCall().url).to.equal(`${AEM_API}/${o}/sites/${s}/media/img.png`); + }); + it('source.getMetadata sends HEAD and returns { ok, status, headers }', async () => { restoreFetch(); installFetch({ status: 200, headers: { 'last-modified': 'Mon, 01 Jan 2025 00:00:00 GMT' } }); From 80f9db79fd836fbeb3c258c4a9fb6d85659bcfbf Mon Sep 17 00:00:00 2001 From: Tobias Bocanegra Date: Fri, 7 Aug 2026 10:42:15 +0200 Subject: [PATCH 4/5] chore: remove workaround --- nx2/utils/api.js | 13 +------------ test/nx2/utils/api.test.js | 34 ---------------------------------- 2 files changed, 1 insertion(+), 46 deletions(-) diff --git a/nx2/utils/api.js b/nx2/utils/api.js index bdafd497e..00e2d253e 100644 --- a/nx2/utils/api.js +++ b/nx2/utils/api.js @@ -349,18 +349,7 @@ export const source = { if (!hlx6) { // fall back to original source store // eslint-disable-next-line no-underscore-dangle - const resp = await source._saveDA({ org, site, path, body }); - if (resp.ok && DA_ADMIN === 'https://stage-admin.da.live') { - // special check for stage content. should be handled in stage da-admin ? - const json = await resp.json(); - const sourceUrl = new URL(json.source.contentUrl); - if (sourceUrl.host === 'content.da.live') { - sourceUrl.host = 'stage-content.da.live'; - json.source.contentUrl = sourceUrl.href; - } - return adaptJsonResponse(resp, json); - } - return resp; + return source._saveDA({ org, site, path, body }); } const url = `${AEM_API}/${org}/sites/${site}/media${path}`; const opts = { diff --git a/test/nx2/utils/api.test.js b/test/nx2/utils/api.test.js index 4ef4a51d4..f00b4e293 100644 --- a/test/nx2/utils/api.test.js +++ b/test/nx2/utils/api.test.js @@ -505,40 +505,6 @@ describe('api.js', () => { expect(calls.some((c) => c.url.includes('/media'))).to.equal(false); }); - it('source.uploadMedia legacy rewrites a content.da.live contentUrl to stage-content.da.live', async () => { - restoreFetch(); - installFetch({ - body: JSON.stringify({ source: { contentUrl: 'https://content.da.live/o/s/img.png' } }), - }); - const { org: o, site: s } = makeOrgSite(); - const resp = await source.uploadMedia({ org: o, site: s, path: '/img.png', body: new Blob(['x']) }); - expect(resp.ok).to.equal(true); - const json = await resp.json(); - expect(json.source.contentUrl).to.equal('https://stage-content.da.live/o/s/img.png'); - }); - - it('source.uploadMedia legacy leaves contentUrl unchanged when host is not content.da.live', async () => { - restoreFetch(); - installFetch({ - body: JSON.stringify({ source: { contentUrl: 'https://stage-content.da.live/o/s/img.png' } }), - }); - const { org: o, site: s } = makeOrgSite(); - const resp = await source.uploadMedia({ org: o, site: s, path: '/img.png', body: new Blob(['x']) }); - expect(resp.ok).to.equal(true); - const json = await resp.json(); - expect(json.source.contentUrl).to.equal('https://stage-content.da.live/o/s/img.png'); - }); - - it('source.uploadMedia legacy returns the response unmodified on failure', async () => { - restoreFetch(); - installFetch({ status: 500, body: '' }); - const { org: o, site: s } = makeOrgSite(); - const resp = await source.uploadMedia({ org: o, site: s, path: '/img.png', body: new Blob(['x']) }); - expect(resp.ok).to.equal(false); - expect(resp.status).to.equal(500); - expect(calls.some((c) => c.url.includes('/media'))).to.equal(false); - }); - it('source.uploadMedia hlx6 POSTs to the media route with content-type header and raw body', async () => { const { org: o, site: s } = makeOrgSite({ hlx6: true }); restoreFetch(); From e0c2fd8dcd3b738229136e92ccbe16bc52c82b35 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 10:53:25 +0200 Subject: [PATCH 5/5] Update worklog --- WORKLOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/WORKLOG.md b/WORKLOG.md index 0abcbd1d9..429b4b1b2 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -1,5 +1,11 @@ # Worklog +## 2026-08-07 + +### nx2/utils/api.js — remove stage-content.da.live rewrite workaround + +`80f9db79` removed the `content.da.live` → `stage-content.da.live` contentUrl rewrite from `source.uploadMedia`'s non-hlx6 branch (added `3300b1ee`/`3070fa63`, see `2026-08-06` below), plus its three dedicated tests. Server-side fix landed on stage-admin.da.live — it now returns the correct content host directly, so the client-side rewrite is no longer needed. This makes bug fix #2 in the `2026-08-06` entry below (the body-stream-already-read fix) moot: it only mattered inside the now-deleted rewrite branch. + ## 2026-08-06 ### nx2/utils/api.js — tests for `source.uploadMedia`, plus two bug fixes found while writing them