Dev - #1139
Conversation
https://ui.contentstack.com/contentstack.min.css now returns HTTP 402 (DEPLOYMENT_DISABLED), so the app lost its grid, utility and brand styles at runtime. - drop the three dead CDN tags from index.html - add bootstrap@^5.3.6 and import bootstrap-grid + bootstrap-utilities, covering the grid/utility half of the old stylesheet - add scss/legacy-cdn-shim.scss for the non-utility brand rules that had no other source (link colour, .btn base, .pt-6, .card headings, .body-4/.body-6, .link-basic-icon with its arrow inlined as a data URI) All imports stay above the venus stylesheet, where the CDN <link> used to sit, so venus keeps winning the same conflicts it won before.
…le-migrated tracking Reference fields (Link/Array Entry) written during a locale-localize restart kept the source CMS entry id instead of the real Contentstack uid, since only the master-locale bulk import resolved references correctly. Threads entry uid-mapper data into the update config so entry-update-script.cjs can resolve them, mirroring the existing asset uid resolution. Also fixes a bug where finishing any locale marked ALL configured locales as migrated, causing not-yet-migrated locales to be silently skipped on later delta restarts. Now only locales actually processed in that run are recorded.
Was reading a literal .id property on the entryId map instead of the dynamic key, so the single-reference branch never took the array path when the mapper legitimately held an array of destination uids.
…le-migrated tracking Address review comments on #1128: - Move iteration 2+ recordMigratedLocales out of runCli into migration.service.ts, after utilsUpdateCli.updateEntryCli resolves. Recording pre-update meant a silent update-CLI failure would still flag the locales as migrated, permanently skipping them on later restarts — same class of bug this PR fixes. - Union in Object.keys(uid-mapper.entryByLocale) so brand-new-entries-only locales (no rows in updated-entries.json because they had no prior csEntryUid) are also recorded as migrated. - Extract flattenNestedUidMap into uid-mapper.utils.js and reuse it in both contentMapper.service and entry-update.utils. entry-update.utils.enrichConfigWithEntryMapping now handles the nested per-content-type shape (and the entryUid variant) instead of assuming a flat map. - entry-update-script.isReferenceArray: switch from .every() to .some(), and pass non-reference items through in resolveReferenceField. Producers legitimately emit mixed arrays (raw Contentful Link objects for unresolved refs, [undefined] from the single-ref fallback) — one such item no longer disables resolution for the whole field.
…action - Clear iter-1 logs and completion flag on new Start Migration; accept 'Entry Update Process Completed' as terminal on the delta path. - Move 'already migrated' placeholder outside the log map so it no longer repeats per line and doesn't hide iter-2 live logs. - extractEntries: use CT displayField with fallbacks instead of hard-coded title/name (fixes CMG-1102 empty Map Entry table).
…traversal Snyk SAST flagged fs.readFileSync on paths derived from projectId. Add explicit assertResolvedPathUnderBase checks so the sink is visibly sanitized against the database dir.
…sanitizer assertResolvedPathUnderBase is a custom helper Snyk SAST doesn't recognize. Apply path.basename inline to every user-derived path segment — the pattern Snyk accepts as a Path Traversal sanitizer.
Direct fs.readFileSync on a projectId-derived path kept flagging as Path Traversal even after path.basename sanitizers. Route the read through the existing getUidMapperDb model — same path resolution but no direct sink in this file.
runCli emits 'Migration Process Completed' but writes uid-mapper and 'No config file generated' lines after it, so a last-log-only check never fired on delta runs. Scan the whole logs array instead — the purge-on-start effect makes this safe from cross-iteration replays.
- CMG-1103 JSON RTE hyperlinks: plain -> type:'a', entry/asset -> type:'reference' with display-type:'link' so URLs survive in destination JSON RTE. - CMG-1104 entry-mapper selection persistence: locale-scoped server toggle, stale-fetch generation guard, unified data-load effect on both contentTypeUid and selectedLocale, tableRevision-based remount so Venus's Table picks up the fresh initialSelectedRowIds after each locale switch. - CMG-1097 asset mapper search-empty state: force .Table height when it contains an .EmptyState and center the .Table__centerWrapper so the illustration + heading + description render properly. - CMG-1105 map-entry count mismatch: getContentTypes now unions content types across all prior iterations (1..N-1) instead of only N-1, so types migrated in earlier iterations but absent from N-1 are still classified as 'old' on iteration N.
- CMG-1106: asset download falls back to fields.file.upload when .url is missing (newly-added, not-yet-CDN-processed Contentful assets). Same fallback in extractAssets.js; skip rows with neither. - CMG-1105: on iteration 2+, Map Entry Assets tab shows only assets already migrated in a prior iteration (has contentstackAssetUid). Brand-new assets upload automatically without a row to select.
fix(ui): replace dead ui.contentstack.com CDN styles with local sources
- entry hyperlinks resolve content-type uid from rte-references instead of a field the Contentful export never populates; fall back to plain text when unresolvable. - asset download no longer crashes on .upload-only assets missing details/fileName; derived once, reused everywhere. - record delta locales even when no update-config was generated this iteration (iteration >= 2, nothing to localize). - entryMapper: safety-net fetch when locale resolution settles empty; drop now-duplicate direct fetchEntries calls. - MigrationLogViewer: terminal-message check is iteration-aware (delta requires the update-CLI's completion message, not the bulk import's). - updateEntryStatus returns 404 instead of a fake 200 when the locale filter matches zero rows. - entry-update-script: resolve references nested inside groups and modular blocks, not just top-level fields. - flattenNestedUidMap: per-key check instead of all-or-nothing, so mixed flat/nested uid maps normalize correctly. - extractEntries: hasAnyLocaleContent checks for meaningful content, not just key presence.
- contentful.service.ts: fix cs_failed.json double-nested write path;
add retryFailedAsset() to re-download a single asset that failed
during the last migration run.
- extractAssets.js: no longer skip assets with no url/upload — emit
with hasSource:false so the UI can show a reason instead of hiding
them silently.
- contentMapper.service.ts: enrich getAssetMapping rows with
status ('ok'|'missing'|'failed') + errorMessage; new
retryAssetDownload service fn; ?status= filter param; aggregate
missingCount/failedCount for the UI banner.
- New route/controller: PUT /mapper/retryAsset/:projectId/:assetUid.
- assetMapper.tsx: Status column with 'No source'/'Failed' badges +
Retry button; broken-asset count banner; status filter dropdown.
- migration.service.ts: write a guaranteed terminal log line after the delta branch regardless of outcome — fixes UI stuck on Execution Logs forever when nothing was selected to update or updateEntryCli failed internally. - contentMapper.service.ts (updateEntryStatus): locale guard only enforced when the row has a language tag (legacy rows are no longer permanently untoggleable); add iteration N-1 fallback mirroring the read side, so restart-fallback rows can be saved without a 404. - contentMapper.service.ts (getAssetMapping): only apply the delta asset filter once uid-mapper data actually exists — avoids showing an empty tab during the restart race before this iteration's uid data is written. - jsonRTE.ts: thread lang/destination_stack_id through parseBlockquote and parseHeading1-6 so hyperlinks nested in a heading/blockquote resolve instead of degrading to plain text. - entry-update-script.cjs: resolveReferencesDeep recurses into every array element instead of delegating arrays to the shallow isReferenceArray path, fixing mixed arrays (bare ref + nested-ref object) where the nested one was left unresolved. Added unit tests. - entryMapper.tsx: remove dead initialRowSelectedData state, unused since the InfiniteScrollTable prop was dropped.
….json Fixes Snyk path-traversal finding in getAssetMapping by reusing the existing sanitizeStackId/assertResolvedPathUnderBase helpers. Also adds unit tests for getAssetMapping and retryFailedAsset to close the function-coverage CI gap.
Adds icons to status badges, an amber (not red) heads-up banner, header/ column overflow fixes so long labels and UIDs stop bleeding into neighboring rows/columns, and rebalanced column widths for Size and Contentstack UID.
Bugfix(delta) | References, Entry Mapping, RTE, empty-state, CT count
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
🔒 Security Scan Results
⏱️ SLA Breach Summary
ℹ️ Vulnerabilities Without Available Fixes (Informational Only)The following vulnerabilities were detected but do not have fixes available (no upgrade or patch). These are excluded from failure thresholds:
✅ BUILD PASSED - All security checks passed |
umesh-more-cstk
left a comment
There was a problem hiding this comment.
Review — dev → pre-stage (20 commits, 34 files, +2192/-241)
Reviewed at head 51c9cbd. Findings below were each traced through the real code at that commit, not inferred from the diff. 2 blockers, 3 questions, 1 nit inline.
Substance is good — the delta-migration fixes (multi-iteration content-type classification, reference-uid resolution through __entryMapping__, deferred migrated_locales recording, the cs_failed.json double-join, the Array.isArray(entryId?.[id]) typo, and the JSON RTE reference-node shapes) are well-targeted and carry real unit tests. Most of what follows is edges around them.
Blockers
upload-api/src/config/index.json— a developer's local machine path (/Users/chetan.narkhede/…) and a hard-codedcmsType: "contentful"were committed over the placeholders. These are the env-var fallbacks inhydrate-config.ts, so any deploy withoutCMS_LOCAL_PATH/CONTAINER_PATHsilently picks them up. Unrelated to everything else in this PR.getNearestPriorUidMapper'shasDataprobe is narrower than its consumers — checksentry/assetswhile readers also useentryUidandentryByLocale, so an iteration that does have uid data gets skipped and a stale older map is returned instead.
Notes that don't anchor to a diff line
-
PR hygiene (blocking for a promotion PR IMO). Title is
Dev; the body is the untouched template — no Jira link (stillMIGRATION-XXXX), no PR Type checked, no Affected Areas checked, no test steps, author checklist all unticked. I can't check "does the diff match the stated Affected Areas" because none are stated, and this diff spansapi,ui, andupload-apiplus a.gitignoreand a config file. Worth filling in before this promotes topre-stage— the config-file blocker above is exactly the kind of thing a self-review pass catches. -
api/src/services/contentful/jsonRTE.ts:98— same bug class this PR is fixing, one line missed. The PR correctly threadslang/destination_stack_idthroughparseBlockquoteandparseHeading1..6, but the table parser still has:.concat(parsers.get('tbody')?.(obj)) // ← no lang, no destination_stack_id
Every other
parsers.get(...)call in the file forwards both. A hyperlink inside a table body will hit the same degradation the PR describes for blockquotes —entry-hyperlinkfalling back to plain text,asset-hyperlinkreturningnulland being dropped by.filter(Boolean). Line 98 isn't in the diff, so it can't be commented inline. One-line fix:parsers.get('tbody')?.(obj, lang, destination_stack_id). -
Scope, minor.
.gitignoreadds.claude/alongsideallien/andaem_data_structure/;bootstrap@^5.3.6is a new runtime dependency inui/package.json. The bootstrap addition is justified inApp.tsx(replacing the deadui.contentstack.comCDN, which now 402s) and only the CSS is imported, so its@popperjs/corepeer dep going uninstalled is fine — just calling out that both land in a PR with no stated areas. -
ui/src/services/api/migration.service.ts:436builds…/${encodedSearchText}?${statusQuery}wherestatusQueryalready starts with&, producing?&status=failed. Express parses it correctly, so this is cosmetic only — mentioning it because the stray&reads like a bug at a glance.
Verified as correct (things I checked and am not flagging)
So the human gate doesn't have to re-derive these:
retryFailedAsset's success check works —saveAssetdoesdelete failedJSON[assets.sys.id]on a successful download (contentful.service.ts:746).packagePathmatches:retryAssetDownloadapplies the samefile_path.replace(/\/$/, '')thatcreateAssetsreceives at migration.service.ts:999.- Passing
metadataas[]in the retry call is harmless — the array is only consumed by the commented-outASSETS_METADATA_FILEwrite. assertResolvedPathUnderBaseis imported inmigration.service.ts(line 46), sorecordDeltaMigratedLocalescompiles.- The new unconditional
'Entry Update Process Completed'marker does reach the client:safeDeltaMigrationLogPath,loggerPath, andrunCli'stransformePathall resolve tologs/<projectId>/<stackId>.log, which is the file the chokidar watcher is pointed at. newMigrationData.iterationexists on the redux shape (app.interface.ts:219), soisDeltaIterationis live rather than always-false.- The UI does send
localein theupdateEntryStatusbody (entryMapper.tsx:452), so the new source-locale scoping is actually exercised. entryMapper.tsx's two fetch effects don't double-fire — React batchessetSelectedLocaleandsetLocalesResolvedin the same continuation, and the safety-net effect is guarded on!selectedLocale?.value.
Comment-only review by design — leaving the merge decision to a human reviewer.
Generated by Claude Code
| "public_path": "drupal_assets_public_path" | ||
| }, | ||
| "localPath": "your_local_cms_data_path" | ||
| "localPath": "/Users/chetan.narkhede/Desktop/migration-v2/asic-v1.json" |
There was a problem hiding this comment.
blocker: A developer's local machine path has been committed here, and cmsType (line 7) was flipped from the "cmsType" placeholder to a hard-coded "contentful". Both look like an accidental commit from local testing (came in with 35a4858, the asset-retry commit) — neither is related to any change in this PR.
This is not inert. upload-api/src/utils/hydrate-config.ts:82-85 uses pickEnvOrExisting(process.env.CMS_TYPE, config.cmsType) / process.env.CMS_LOCAL_PATH || process.env.CONTAINER_PATH — the committed JSON value is the fallback when the env var is unset, and the file is also imported directly by upload-api/src/services/aws/client.ts:2.
Failure scenario: deploy pre-stage without CMS_LOCAL_PATH/CONTAINER_PATH set. Previously the resolved value was the obvious-garbage placeholder your_local_cms_data_path, which fails loudly; now upload-api silently resolves its source path to /Users/chetan.narkhede/Desktop/migration-v2/asic-v1.json and every CMS type defaults to contentful regardless of what the user selected.
Suggested fix: revert this file to the placeholders.
"cmsType": "cmsType",
...
"localPath": "your_local_cms_data_path"Generated by Claude Code
| await model.read(); | ||
| const data = model?.data as any; | ||
| const hasData = | ||
| Object.keys(data?.entry ?? {}).length > 0 || |
There was a problem hiding this comment.
blocker: hasData only inspects entry and assets, but the consumers of the model this returns read three other shapes — so an iteration that does carry uid data gets classified as empty and skipped.
getEntryUidMap(just below, ~line 2264-2280) resolves fromd.entryUidord.entry. A uid-mapper holding mappings only underentryUidpasseshasData === falsehere.writePerLocaleEntryUidMapping(api/src/utils/uid-mapper.utils.ts:178) writesentryByLocaleand its own docblock says it "Leaves the flatentry/assetsmaps untouched" — so an iteration with per-locale mappings but no flat map also reads as empty.
Failure scenario: iteration 3 on a project where iteration 2's uid-mapper.json has entryByLocale: { "en-in": {...} } and entry: {}. The loop skips iteration 2 and returns iteration 1's model instead. enrichEntriesWithUidMapper then resolves against a stale, older map — Map Entry shows -/wrong Contentstack UIDs for entries that iteration 2 actually migrated, and getAssetMapping's hasAnyUidData computes false, which disables the delta filter entirely (line ~2537).
Suggested fix — align the probe with what the readers actually consume:
const hasData =
Object.keys(data?.entry ?? {}).length > 0 ||
Object.keys(data?.entryUid ?? {}).length > 0 ||
Object.keys(data?.entryByLocale ?? {}).length > 0 ||
Object.keys(data?.assets ?? {}).length > 0;Generated by Claude Code
| // dirPath arg), which wrote to `<assetsSave>/cs_failed.json/cs_failed.json` — a directory | ||
| // named cs_failed.json containing a file of the same name — instead of the intended | ||
| // `<assetsSave>/cs_failed.json`. Pass the directory alone. | ||
| failedJSON && await writeFile(assetsSave, ASSETS_FAILED_FILE, failedJSON); |
There was a problem hiding this comment.
question: The double-join fix is correct (writeFile at line 355-365 does path.join(dirPath, filename) itself), but is there an upgrade path for data directories written by the old code?
The pre-fix call was writeFile(path.join(assetsSave, ASSETS_FAILED_FILE), ASSETS_FAILED_FILE, ...), and writeFile starts with mkdir(dirPath, { recursive: true }) — so every project that ran asset creation before this commit has a directory named cs_failed.json sitting at <assetsSave>/cs_failed.json.
Traced against the new code, that stale directory silently disables the whole failed/retry feature on those projects:
- Here:
writeOneFile→fs.promises.writeFile('<assetsSave>/cs_failed.json', ...)on an existing directory throwsEISDIR, whichwriteFile'scatchswallows to aconsole.error(line 363). Nothing reachescustomLogger, so it never appears in the user's migration log. contentMapper.service.tsgetAssetMapping(~line 2500):fs.existsSync(failedPath)returns true for the directory,readFileSyncthrowsEISDIR, the barecatch {}setsfailedAssets = {}— so every row reportsstatus: 'ok'andfailedCount: 0.
Net effect on an upgraded project: assets that failed to download are reported as Ready, the "N assets won't be migrated" banner never appears, and the Retry button is never rendered — with no error anywhere the user can see.
Suggested fix: remove the stale entry before writing, and surface the write failure rather than swallowing it:
const failedPath = path.join(assetsSave, ASSETS_FAILED_FILE);
if (fs.existsSync(failedPath) && (await fs.promises.stat(failedPath)).isDirectory()) {
await fs.promises.rm(failedPath, { recursive: true, force: true });
}
await writeFile(assetsSave, ASSETS_FAILED_FILE, failedJSON);(Also worth logging via customLogger in writeFile's catch so a future silent write failure is visible.) If the deployed data directories are recreated from scratch on every release, then only the swallowed-error half applies — happy to drop this if that's the case.
Generated by Claude Code
| ): Promise<{ success: boolean; message: string }> => { | ||
| const srcFunc = 'retryFailedAsset'; | ||
| try { | ||
| const assetsSave = path.join(DATA, destination_stack_id, ASSETS_DIR_NAME); |
There was a problem hiding this comment.
question: retryFailedAsset reaches filesystem sinks with unsanitized values, while the sibling function added in this same PR sanitizes the identical value.
contentMapper.service.ts getAssetMapping (~line 2495) carries an explicit comment for exactly this reason:
destination_stack_idis DB-stored, but Snyk's taint tracker still traces it back to the HTTPprojectIdparam via the lowdb lookup above — sanitize it the same way the rest of this codebase does … before it reaches areadFileSyncsink.
…and applies sanitizeStackId + assertResolvedPathUnderBase. But retryAssetDownload (contentMapper.service.ts ~2400-2430) passes projectData?.destination_stack_id and projectData?.legacy_cms?.file_path straight through, and here they land in path.join(DATA, destination_stack_id, ASSETS_DIR_NAME) (line 876) plus fs.promises.readFile(packagePath, 'utf8') (line 880) — the same lowdb-sourced values, reached from the same HTTP :projectId param, with no sanitizer in between.
Failure scenario: whether or not it's exploitable today (both values are DB-written, not taken from the request body), the asymmetry means Snyk SAST will flag this new sink the same way it flagged the one 60 lines above — so CI will likely reject it — and any future code path that lets destination_stack_id be set from user input escapes the data directory here with no guard.
Suggested fix: apply the same two-step guard in retryAssetDownload before delegating:
const safeStackId = sanitizeStackId(destinationStackId);
if (!safeStackId) {
return { status: HTTP_CODES?.BAD_REQUEST, data: { message: 'Invalid destination stack id.' } };
}
// pass safeStackId to retryFailedAsset, and assertResolvedPathUnderBase(path.resolve(DATA), assetsSave) inside itGenerated by Claude Code
| // this asset. Still emit the row (rather than silently dropping it) so the user sees | ||
| // *why* it's missing instead of the asset count on Map Entry mysteriously not | ||
| // matching the source export. `hasSource: false` rows are always non-selectable. | ||
| hasSource: Boolean(assetPath), |
There was a problem hiding this comment.
question: The comment above states "hasSource: false rows are always non-selectable", but I can't find anything enforcing that on the UI side.
Selectability is decided solely by ui/src/components/ContentMapper/assetMapper.utils.ts:25:
_canSelect: !!asset?.contentstackAssetUid,hasSource isn't consulted anywhere in ui/src (only api/src/services/contentMapper.service.ts:2551 reads it, to derive status: 'missing').
Failure scenario: an asset migrated successfully in iteration 1 (so it has a contentstackAssetUid) is re-exported in iteration 2 with its file removed from the Contentful space, so extractAssets emits hasSource: false. The row renders the "No source" badge, but _canSelect is true, so the checkbox is live — the user can queue it for update and Save. getAssetMapping's new delta filter (contentMapper.service.ts ~2537) deliberately keeps missing rows visible even without a uid, which makes this combination easy to hit.
Suggested fix: fold hasSource/status into the selectability decision so the comment holds:
_canSelect: !!asset?.contentstackAssetUid && asset?.hasSource !== false && asset?.status !== 'missing',If selecting a missing-source asset is in fact harmless (the update silently no-ops), then the comment here is the thing to correct instead.
Generated by Claude Code
| setTableData((prev) => | ||
| prev.map((row) => | ||
| row.otherCmsAssetUid === sourceUid | ||
| ? { ...row, status: 'ok', errorMessage: undefined } |
There was a problem hiding this comment.
nit: A successful retry flips the row to status: 'ok' but leaves failedCount/missingCount untouched, so the banner they drive goes stale.
Failure scenario: one failed asset, banner reads "1 asset won't be migrated because the source file is broken or missing." User clicks Retry, it succeeds, the badge turns to Ready — and the banner still says 1. It only corrects on the next fetch (page change, search, or status-filter change). If the user is currently filtered to Failed, the retried row also stays in the list showing "Ready", since the filter was applied server-side.
Suggested fix: decrement alongside the row update in the success branch:
setFailedCount((c) => Math.max(0, c - 1));Or, if you'd rather keep one source of truth, re-run fetchAssets(searchText, { seedSelection: false }) after a successful retry — that also drops the row out of a failed-filtered view.
Generated by Claude Code
🔗 Jira Ticket
MIGRATION-XXXX
📋 PR Type
📝 Description
What changed?
Why?
🧩 Affected Areas
api— Node.js backendui— React frontendupload-api— Upload API serverdocker/docker-compose🧪 How to Test
Expected result:
📸 Screenshots / Recordings
🔗 Related PRs / Dependencies
✅ Author Checklist
feature/,bugfix/, orhotfix/+ 5–30 lowercase chars.env/example.envupdated if new environment variables were addednpm test)README.md/ docs updated if behaviour changed👀 Reviewer Notes