Skip to content

Commit 2849d2a

Browse files
authored
Merge branch 'v2-dev' into feat/DX-9314
2 parents 36f8f8f + 6088508 commit 2849d2a

7 files changed

Lines changed: 411 additions & 16 deletions

File tree

packages/contentstack-asset-management/src/export/assets.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import { resolve as pResolve } from 'node:path';
22
import { Readable } from 'node:stream';
33
import { mkdir, writeFile } from 'node:fs/promises';
44
import chunk from 'lodash/chunk';
5-
import { configHandler, log, FsUtility } from '@contentstack/cli-utilities';
5+
import { log, FsUtility } from '@contentstack/cli-utilities';
66

77
import type { CSAssetsAPIConfig, LinkedWorkspace } from '../types/cs-assets-api';
88
import type { ExportContext } from '../types/export-types';
99
import { CSAssetsExportAdapter } from './base';
10-
import { writeStreamToFile, getArrayFromResponse } from '../utils/export-helpers';
10+
import { writeStreamToFile, getArrayFromResponse, getSecuredAssetAuth, SecuredAssetAuthError } from '../utils/export-helpers';
11+
import type { SecuredAssetAuth } from '../utils/export-helpers';
1112
import { forEachChunkedJsonStore } from '../utils/chunked-json-reader';
1213
import { withRetry, RetryableHttpError, isRetryableStatus, parseRetryAfterMs } from '../utils/retry';
1314
import type { CustomPromiseHandler } from '../utils/cs-assets-api-adapter';
@@ -111,7 +112,6 @@ export default class ExportAssets extends CSAssetsExportAdapter {
111112
await mkdir(filesDir, { recursive: true });
112113

113114
const securedAssets = this.exportContext.securedAssets ?? false;
114-
const authtoken = securedAssets ? configHandler.get('authtoken') : null;
115115
log.debug(
116116
`Asset downloads: securedAssets=${securedAssets}, concurrency=${this.downloadAssetsBatchConcurrency}, expected=${expectedDownloads}`,
117117
this.exportContext.context,
@@ -120,6 +120,9 @@ export default class ExportAssets extends CSAssetsExportAdapter {
120120

121121
let downloadOk = 0;
122122
let downloadFail = 0;
123+
// Set when a 401 persists after a forced token refresh — from then on, skip the network
124+
// entirely and abort the phase, instead of individually failing every remaining asset.
125+
let authFailure: SecuredAssetAuthError | null = null;
123126

124127
await forEachChunkedJsonStore<AssetRecord>(
125128
assetsDir,
@@ -154,25 +157,60 @@ export default class ExportAssets extends CSAssetsExportAdapter {
154157
async (records) => {
155158
const valid = records.filter((asset) => this.isDownloadable(asset));
156159
if (valid.length === 0) return;
160+
const chunkAuthFailure = authFailure;
161+
if (chunkAuthFailure) {
162+
// Auth already failed hard — count the remaining downloadables without hitting the network.
163+
for (const rec of valid) {
164+
downloadFail += 1;
165+
this.tick(false, `asset: ${rec.filename ?? rec.file_name ?? rec.uid ?? 'asset'}`, chunkAuthFailure.message);
166+
}
167+
return;
168+
}
169+
// Resolve per chunk so long download phases pick up proactively refreshed OAuth tokens.
170+
let auth: SecuredAssetAuth = securedAssets ? await getSecuredAssetAuth() : {};
157171
const apiBatches = chunk(valid, this.downloadAssetsBatchConcurrency);
158172
const promisifyHandler: CustomPromiseHandler = async ({ index, batchIndex }) => {
159173
const asset = apiBatches[batchIndex][index] as AssetRecord;
160174
const uid = (asset.uid ?? asset._uid) as string;
161175
const url = asset.url as string;
162176
const filename = asset.filename ?? asset.file_name ?? 'asset';
163177
if (!url || !uid) return;
178+
const knownAuthFailure = authFailure as SecuredAssetAuthError | null;
179+
if (knownAuthFailure) {
180+
downloadFail += 1;
181+
this.tick(false, `asset: ${filename}`, knownAuthFailure.message);
182+
return;
183+
}
164184
try {
165185
const separator = url.includes('?') ? '&' : '?';
166-
const downloadUrl = securedAssets && authtoken ? `${url}${separator}authtoken=${authtoken}` : url;
186+
const doFetch = () =>
187+
fetch(
188+
securedAssets && auth.authtoken ? `${url}${separator}authtoken=${auth.authtoken}` : url,
189+
securedAssets && auth.headers ? { headers: auth.headers } : undefined,
190+
);
167191
// Binary GET is idempotent — retry transient failures with backoff.
168192
const response = await withRetry(
169193
async () => {
170194
let resp: Response;
171195
try {
172-
resp = await fetch(downloadUrl);
196+
resp = await doFetch();
173197
} catch (e) {
174198
throw new RetryableHttpError(`download network error: ${(e as Error)?.message ?? String(e)}`);
175199
}
200+
if (securedAssets && resp.status === 401) {
201+
// Token expired or was revoked mid-run — force one refresh (deduped upstream)
202+
// and refetch. A second 401 means auth is unrecoverable: abort the phase.
203+
auth = await getSecuredAssetAuth(true);
204+
try {
205+
resp = await doFetch();
206+
} catch (e) {
207+
throw new RetryableHttpError(`download network error: ${(e as Error)?.message ?? String(e)}`);
208+
}
209+
if (resp.status === 401) {
210+
authFailure = new SecuredAssetAuthError(resp.status);
211+
throw authFailure;
212+
}
213+
}
176214
if (!resp.ok) {
177215
if (isRetryableStatus(resp.status)) {
178216
throw new RetryableHttpError(`HTTP ${resp.status}`, resp.status, parseRetryAfterMs(resp.headers.get('retry-after')));
@@ -209,6 +247,16 @@ export default class ExportAssets extends CSAssetsExportAdapter {
209247
},
210248
);
211249

250+
const terminalAuthFailure = authFailure as SecuredAssetAuthError | null;
251+
if (terminalAuthFailure) {
252+
// Fail the space loudly — a "completed with errors" summary would bury the real cause.
253+
log.error(
254+
`Aborted asset downloads for space ${spaceUid}: ${terminalAuthFailure.message}`,
255+
this.exportContext.context,
256+
);
257+
throw terminalAuthFailure;
258+
}
259+
212260
log.info(
213261
downloadFail === 0
214262
? `Finished downloading ${downloadOk} asset file(s) for space ${spaceUid}`

packages/contentstack-asset-management/src/query-export/cs-assets-query-exporter.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { resolve as pResolve } from 'node:path';
22
import { mkdir, writeFile } from 'node:fs/promises';
33
import { Readable } from 'node:stream';
4-
import { log, handleAndLogError, configHandler } from '@contentstack/cli-utilities';
4+
import { log, handleAndLogError } from '@contentstack/cli-utilities';
55

66
import type { CsAssetsQueryExportOptions, CSAssetsAPIConfig, LinkedWorkspace } from '../types/cs-assets-api';
77
import type { ExportContext } from '../types/export-types';
88
import ExportAssetTypes from '../export/asset-types';
99
import ExportFields from '../export/fields';
1010
import { CSAssetsExportAdapter } from '../export/base';
1111
import chunk from 'lodash/chunk';
12-
import { getAssetItems, writeStreamToFile } from '../utils/export-helpers';
12+
import { getAssetItems, writeStreamToFile, getSecuredAssetAuth, SecuredAssetAuthError } from '../utils/export-helpers';
13+
import type { SecuredAssetAuth } from '../utils/export-helpers';
1314
import { withRetry, RetryableHttpError, isRetryableStatus, parseRetryAfterMs } from '../utils/retry';
1415
import type { CustomPromiseHandler } from '../utils/cs-assets-api-adapter';
1516

@@ -223,26 +224,64 @@ class QueryExportWorkspaceAdapter extends CSAssetsExportAdapter {
223224
await mkdir(filesDir, { recursive: true });
224225

225226
const securedAssets = this.exportContext.securedAssets ?? false;
226-
const authtoken = securedAssets ? configHandler.get('authtoken') : null;
227+
// Set when a 401 persists after a forced token refresh — from then on, skip the network
228+
// entirely and abort the phase, instead of individually failing every remaining asset.
229+
let authFailure: SecuredAssetAuthError | null = null;
230+
// OAuth → Authorization: Bearer header; basic auth → ?authtoken= query param.
231+
// Resolved lazily once per sequential batch (handlers within a batch share one resolve), so
232+
// long download runs keep picking up proactively refreshed OAuth tokens.
233+
let auth: SecuredAssetAuth = {};
234+
let authBatchIndex = -1;
235+
let authResolve: Promise<void> = Promise.resolve();
236+
const ensureAuthForBatch = (batchIndex: number): Promise<void> => {
237+
if (!securedAssets) return Promise.resolve();
238+
if (batchIndex !== authBatchIndex) {
239+
authBatchIndex = batchIndex;
240+
authResolve = getSecuredAssetAuth().then((resolved) => {
241+
auth = resolved;
242+
});
243+
}
244+
return authResolve;
245+
};
227246

228247
const apiBatches = chunk(downloadable, this.downloadAssetsBatchConcurrency);
229248
const promisifyHandler: CustomPromiseHandler = async ({ index, batchIndex }) => {
230249
const asset = apiBatches[batchIndex][index];
231250
const uid = String(asset.uid ?? asset._uid);
232251
const url = String(asset.url);
233252
const filename = String(asset.filename ?? asset.file_name ?? 'asset');
253+
if (authFailure) return; // auth failed hard — don't hit the network for remaining assets
234254
try {
255+
await ensureAuthForBatch(batchIndex);
235256
const separator = url.includes('?') ? '&' : '?';
236-
const downloadUrl = securedAssets && authtoken ? `${url}${separator}authtoken=${authtoken}` : url;
257+
const doFetch = () =>
258+
fetch(
259+
securedAssets && auth.authtoken ? `${url}${separator}authtoken=${auth.authtoken}` : url,
260+
securedAssets && auth.headers ? { headers: auth.headers } : undefined,
261+
);
237262
// Binary GET is idempotent — retry transient failures with backoff.
238263
const response = await withRetry(
239264
async () => {
240265
let resp: Response;
241266
try {
242-
resp = await fetch(downloadUrl);
267+
resp = await doFetch();
243268
} catch (e) {
244269
throw new RetryableHttpError(`download network error: ${(e as Error)?.message ?? String(e)}`);
245270
}
271+
if (securedAssets && resp.status === 401) {
272+
// Token expired or was revoked mid-run — force one refresh (deduped upstream)
273+
// and refetch. A second 401 means auth is unrecoverable: abort the phase.
274+
auth = await getSecuredAssetAuth(true);
275+
try {
276+
resp = await doFetch();
277+
} catch (e) {
278+
throw new RetryableHttpError(`download network error: ${(e as Error)?.message ?? String(e)}`);
279+
}
280+
if (resp.status === 401) {
281+
authFailure = new SecuredAssetAuthError(resp.status);
282+
throw authFailure;
283+
}
284+
}
246285
if (!resp.ok) {
247286
if (isRetryableStatus(resp.status)) {
248287
throw new RetryableHttpError(`HTTP ${resp.status}`, resp.status, parseRetryAfterMs(resp.headers.get('retry-after')));
@@ -265,5 +304,15 @@ class QueryExportWorkspaceAdapter extends CSAssetsExportAdapter {
265304
};
266305

267306
await this.makeConcurrentCall({ apiBatches, module: 'asset downloads' }, promisifyHandler);
307+
308+
const terminalAuthFailure = authFailure as SecuredAssetAuthError | null;
309+
if (terminalAuthFailure) {
310+
// Fail the space loudly — silently skipping the rest would look like a successful export.
311+
log.error(
312+
`Aborted asset downloads for space ${spaceUid}: ${terminalAuthFailure.message}`,
313+
this.exportContext.context,
314+
);
315+
throw terminalAuthFailure;
316+
}
268317
}
269318
}

packages/contentstack-asset-management/src/utils/export-helpers.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,51 @@
11
import { createWriteStream } from 'node:fs';
2+
import { authHandler, authenticationHandler, configHandler } from '@contentstack/cli-utilities';
3+
4+
export interface SecuredAssetAuth {
5+
/** OAuth: header to attach to the download fetch (value is already "Bearer <token>"). */
6+
headers?: Record<string, string>;
7+
/** Basic auth: token to append as ?authtoken= (existing behavior). */
8+
authtoken?: string;
9+
}
10+
11+
/**
12+
* Terminal auth failure for secured asset downloads: the server kept rejecting the token even
13+
* after a forced refresh. Download loops throw this to abort the whole phase instead of failing
14+
* every remaining asset individually.
15+
*/
16+
export class SecuredAssetAuthError extends Error {
17+
readonly status: number;
18+
19+
constructor(status: number) {
20+
super(
21+
`Secured asset download authentication failed (HTTP ${status}) even after refreshing credentials. ` +
22+
'Please log in again (csdx auth:login) and re-run the export.',
23+
);
24+
this.name = 'SecuredAssetAuthError';
25+
this.status = status;
26+
}
27+
}
28+
29+
/**
30+
* Resolve auth for secured asset binary downloads.
31+
* OAuth → Authorization: Bearer header (getAuthDetails handles proactive expiry refresh).
32+
* Basic → authtoken query param (existing behavior).
33+
*
34+
* Pass `forceRefresh` after a 401: the server rejected a token that is still inside its local
35+
* expiry window (revoked/invalidated), so force a refresh — concurrent callers are deduped by
36+
* authHandler's in-flight refresh promise. No-op for basic auth, which cannot be refreshed.
37+
*/
38+
export async function getSecuredAssetAuth(forceRefresh = false): Promise<SecuredAssetAuth> {
39+
if (forceRefresh && authenticationHandler.isOauthEnabled) {
40+
await authHandler.compareOAuthExpiry(true);
41+
}
42+
await authenticationHandler.getAuthDetails();
43+
if (authenticationHandler.isOauthEnabled) {
44+
return { headers: { authorization: authenticationHandler.accessToken } };
45+
}
46+
const authtoken = configHandler.get('authtoken');
47+
return authtoken ? { authtoken } : {};
48+
}
249

350
export function getArrayFromResponse(data: unknown, arrayKey: string): unknown[] {
451
if (Array.isArray(data)) return data;

packages/contentstack-asset-management/src/utils/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ export {
55
getArrayFromResponse,
66
getAssetItems,
77
getReadableStreamFromDownloadResponse,
8+
getSecuredAssetAuth,
9+
SecuredAssetAuthError,
810
writeStreamToFile,
911
} from './export-helpers';
12+
export type { SecuredAssetAuth } from './export-helpers';
1013
export { chunkArray, runInBatches } from './concurrent-batch';
1114
export { withRetry, RetryableHttpError, isRetryableStatus, parseRetryAfterMs } from './retry';
1215
export { detectAssetManagementExportFromContentDir } from './detect-asset-management-export';

packages/contentstack-asset-management/test/unit/export/assets.test.ts

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { expect } from 'chai';
22
import sinon from 'sinon';
3-
import { configHandler } from '@contentstack/cli-utilities';
43

54
import ExportAssets from '../../../src/export/assets';
65
import { CSAssetsExportAdapter } from '../../../src/export/base';
76
import * as chunkedJsonReader from '../../../src/utils/chunked-json-reader';
7+
import * as exportHelpers from '../../../src/utils/export-helpers';
88
import * as retryModule from '../../../src/utils/retry';
99

1010
import type { CSAssetsAPIConfig, LinkedWorkspace } from '../../../src/types/cs-assets-api';
@@ -230,7 +230,7 @@ describe('ExportAssets', () => {
230230
});
231231

232232
it('should append authtoken to URL when securedAssets is true', async () => {
233-
sinon.stub(configHandler, 'get').returns('my-auth-token');
233+
sinon.stub(exportHelpers, 'getSecuredAssetAuth').resolves({ authtoken: 'my-auth-token' });
234234
wireStreaming([{ uid: 'a1', url: 'https://cdn.example.com/a1.png', filename: 'img.png' }] as any);
235235
fetchStub.callsFake(async () => makeFetchResponse() as any);
236236

@@ -241,7 +241,7 @@ describe('ExportAssets', () => {
241241
});
242242

243243
it('should use "&" separator when URL already contains "?"', async () => {
244-
sinon.stub(configHandler, 'get').returns('my-token');
244+
sinon.stub(exportHelpers, 'getSecuredAssetAuth').resolves({ authtoken: 'my-token' });
245245
wireStreaming([{ uid: 'a1', url: 'https://cdn.example.com/a1?v=1', filename: 'img.png' }] as any);
246246
fetchStub.callsFake(async () => makeFetchResponse() as any);
247247

@@ -279,4 +279,87 @@ describe('ExportAssets', () => {
279279
expect(assetTicks[0].args[2]).to.equal('No response body');
280280
});
281281
});
282+
283+
describe('secured asset downloads', () => {
284+
const securedContext: ExportContext = { ...exportContext, securedAssets: true };
285+
const asset = { uid: 'a1', url: 'https://cdn.example.com/a1.png', filename: 'img.png' };
286+
const make401 = () => ({ ok: false, status: 401, headers: { get: (): string | null => null } });
287+
288+
it('should send the Authorization header (and no authtoken param) for OAuth', async () => {
289+
wireStreaming([asset] as any);
290+
sinon.stub(exportHelpers, 'getSecuredAssetAuth').resolves({ headers: { authorization: 'Bearer oauth-token' } });
291+
fetchStub.callsFake(async () => makeFetchResponse() as any);
292+
293+
const exporter = new ExportAssets(apiConfig, securedContext);
294+
await exporter.start(workspace, spaceDir);
295+
296+
const [url, init] = fetchStub.firstCall.args;
297+
expect(url).to.equal(asset.url);
298+
expect(init).to.deep.equal({ headers: { authorization: 'Bearer oauth-token' } });
299+
});
300+
301+
it('should append ?authtoken= (and no headers) for basic auth', async () => {
302+
wireStreaming([asset] as any);
303+
sinon.stub(exportHelpers, 'getSecuredAssetAuth').resolves({ authtoken: 'basic-token' });
304+
fetchStub.callsFake(async () => makeFetchResponse() as any);
305+
306+
const exporter = new ExportAssets(apiConfig, securedContext);
307+
await exporter.start(workspace, spaceDir);
308+
309+
const [url, init] = fetchStub.firstCall.args;
310+
expect(url).to.equal(`${asset.url}?authtoken=basic-token`);
311+
expect(init).to.be.undefined;
312+
});
313+
314+
it('should not resolve auth or attach credentials for unsecured exports', async () => {
315+
wireStreaming([asset] as any);
316+
const authStub = sinon.stub(exportHelpers, 'getSecuredAssetAuth');
317+
fetchStub.callsFake(async () => makeFetchResponse() as any);
318+
319+
const exporter = new ExportAssets(apiConfig, exportContext);
320+
await exporter.start(workspace, spaceDir);
321+
322+
expect(authStub.called).to.be.false;
323+
const [url, init] = fetchStub.firstCall.args;
324+
expect(url).to.equal(asset.url);
325+
expect(init).to.be.undefined;
326+
});
327+
328+
it('should force-refresh once on 401 and succeed with the fresh token', async () => {
329+
wireStreaming([asset] as any);
330+
const authStub = sinon
331+
.stub(exportHelpers, 'getSecuredAssetAuth')
332+
.callsFake(async (force?: boolean) =>
333+
force ? { headers: { authorization: 'Bearer fresh' } } : { headers: { authorization: 'Bearer stale' } },
334+
);
335+
fetchStub.callsFake(async (_url: any, init: any) =>
336+
init?.headers?.authorization === 'Bearer fresh' ? (makeFetchResponse() as any) : (make401() as any),
337+
);
338+
339+
const exporter = new ExportAssets(apiConfig, securedContext);
340+
await exporter.start(workspace, spaceDir);
341+
342+
expect(authStub.calledWith(true)).to.be.true;
343+
expect(fetchStub.callCount).to.equal(2);
344+
const tickStub = (CSAssetsExportAdapter.prototype as any).tick as sinon.SinonStub;
345+
const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:'));
346+
expect(assetTicks).to.have.length(1);
347+
expect(assetTicks[0].args[0]).to.be.true;
348+
});
349+
350+
it('should abort the space with SecuredAssetAuthError when 401 persists after refresh', async () => {
351+
wireStreaming(assetItems);
352+
sinon.stub(exportHelpers, 'getSecuredAssetAuth').resolves({ headers: { authorization: 'Bearer bad' } });
353+
fetchStub.callsFake(async () => make401() as any);
354+
355+
const exporter = new ExportAssets(apiConfig, securedContext);
356+
try {
357+
await exporter.start(workspace, spaceDir);
358+
expect.fail('should have thrown');
359+
} catch (e: any) {
360+
expect(e).to.be.instanceOf(exportHelpers.SecuredAssetAuthError);
361+
expect(e.message).to.include('401');
362+
}
363+
});
364+
});
282365
});

0 commit comments

Comments
 (0)