diff --git a/WORKLOG.md b/WORKLOG.md index 4961c5797..429b4b1b2 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -1,5 +1,21 @@ # 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 + +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) diff --git a/nx2/utils/api.js b/nx2/utils/api.js index e7a2f6805..b8a1ab0f1 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; } @@ -296,31 +296,95 @@ 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)) { + // eslint-disable-next-line no-underscore-dangle + return source._saveHlx6(opts); + } + // eslint-disable-next-line no-underscore-dangle + return source._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 + // eslint-disable-next-line no-underscore-dangle + return source._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(); + // { + // 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, + }, + // 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); @@ -622,6 +686,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); @@ -700,12 +773,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) { diff --git a/test/nx2/utils/api.test.js b/test/nx2/utils/api.test.js index d3ab5c5e6..09eee93c0 100644 --- a/test/nx2/utils/api.test.js +++ b/test/nx2/utils/api.test.js @@ -488,6 +488,94 @@ 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 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' } });