Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ export const DATABASE_FILES = {
CONTENT_TYPES_MAPPER: 'contentTypesMapper.json',
FIELD_MAPPER: 'field-mapper.json',
ENTRY_MAPPER: 'entry-mapper.json',
ASSET_MAPPER: 'asset-mapper.json',
UID_MAPPER: 'uid-mapper.json',
UPDATED_ENTRIES: 'updated-entries.json',
ASSET_METADATA: 'asset-metadata.json',
Expand Down
28 changes: 27 additions & 1 deletion api/src/controllers/projects.contentMapper.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,30 @@ const updateEntryStatus = async (req: Request, res: Response): Promise<void> =>
res.status(resp?.status).json(resp);
};

/**
* Retrieves the asset mapping for a project.
*
* @param req - The request object.
* @param res - The response object.
* @returns A Promise that resolves to void.
*/
const getAssetMapping = async (req: Request, res: Response): Promise<void> => {
const resp = await contentMapperService.getAssetMapping(req);
res.status(resp?.status).json(resp);
};

/**
* Toggles the reuse/re-import decision for the given asset mapper rows.
*
* @param req - The request object.
* @param res - The response object.
* @returns A Promise that resolves to void.
*/
const updateAssetStatus = async (req: Request, res: Response): Promise<void> => {
const resp = await contentMapperService.updateAssetStatus(req);
res.status(resp?.status).json(resp);
};

export const contentMapperController = {
getContentTypes,
getFieldMapping,
Expand All @@ -203,5 +227,7 @@ export const contentMapperController = {
getExistingGlobalFields,
getSingleGlobalField,
getEntryMapping,
updateEntryStatus
updateEntryStatus,
getAssetMapping,
updateAssetStatus
};
60 changes: 60 additions & 0 deletions api/src/models/assetMapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { JSONFile } from "lowdb/node";
import LowWithLodash from "../utils/lowdb-lodash.utils.js";
import path from "path";
import fs from 'node:fs';
import { DATABASE_FILES } from "../constants/index.js";
import { sanitizeProjectId } from "../utils/sanitize-path.utils.js";

/**
* Represents an asset mapper object. Rows exist only for assets whose source
* uid is stable across export runs; isUpdate=true means "update the existing
* Contentstack asset in place (same UID, new file)" on a delta iteration,
* isUpdate=false means "keep/reuse the existing asset as-is".
*/
export interface AssetMapper {
asset_mapper: {
id: string;
projectId: string;
otherCmsAssetUid: string;
filename: string;
title: string;
file_size: number | string;
assetPath: string;
isUpdate: boolean;
contentstackAssetUid: string;
isChanged: boolean;
}[];
}

const defaultData: AssetMapper = { asset_mapper: [] };

/**
* Creates and returns a database instance for the asset mapper for a specific
* project and iteration.
* @param projectId - The unique identifier of the project
* @param iteration - The migration iteration the mapping belongs to
* @returns The database instance for the asset mapper
*/
const getAssetMapperDb = (projectId: string, iteration: number) => {
// projectId is HTTP-derived in several routes; validate it via an allowlist
// before using it as a path segment to prevent path traversal (CWE-23).
// sanitizeProjectId returns null for unsafe input (e.g. containing "..", "/").
const safeProjectId = sanitizeProjectId(projectId);
if (safeProjectId === null) {
throw new Error("Invalid projectId");
}
const dir = path.join(
process.cwd(),
DATABASE_FILES.DIRECTORY,
safeProjectId,
iteration.toString()
);
fs.mkdirSync(dir, { recursive: true });
const db = new LowWithLodash(
new JSONFile<AssetMapper>(path.join(dir, DATABASE_FILES.ASSET_MAPPER)),
defaultData
);
return db;
};

export default getAssetMapperDb;
18 changes: 18 additions & 0 deletions api/src/routes/contentMapper.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,24 @@ router.put(
asyncRouter(contentMapperController.updateEntryStatus)
);

/**
* Get Asset Mapping List
* @route GET /assetMapping/:projectId/:skip/:limit/:searchText?
*/
router.get(
"/assetMapping/:projectId/:skip/:limit/:searchText?",
asyncRouter(contentMapperController.getAssetMapping)
);

/**
* Update Asset Status (toggle reuse/re-import per asset)
* @route PUT /updateAssetStatus/:projectId
*/
router.put(
"/updateAssetStatus/:projectId",
asyncRouter(contentMapperController.updateAssetStatus)
);

/**
* Get Single Global Field data
* @route GET /:projectId/:globalFieldUid
Expand Down
102 changes: 98 additions & 4 deletions api/src/services/aem.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/* eslint-disable no-unsafe-optional-chaining */
import fs from 'fs';
import path from 'path';
import { createHash } from 'node:crypto';
import read from 'fs-readdir-recursive';
import _ from "lodash";
import { v4 as uuidv4 } from 'uuid';
Expand Down Expand Up @@ -265,7 +266,15 @@ function getCurrentLocale(parseData: any): string | undefined {
}

function getLocaleFromMapper(mapper: Record<string, string>, locale: string): string | undefined {
return Object.keys(mapper).find(key => mapper[key] === locale);
const target = locale?.toLowerCase?.();
const exact = Object.keys(mapper).find(key => mapper[key]?.toLowerCase?.() === target);
if (exact) return exact;
// Fall back to base-language match: source "en-US" should match mapper value "en"
const base = target?.split?.('-')?.[0];
return Object.keys(mapper).find(key => {
const v = mapper[key]?.toLowerCase?.();
return v === base || v?.split?.('-')?.[0] === base;
});
}

const deepFlattenObject = (obj: any, prefix = '', res: any = {}) => {
Expand Down Expand Up @@ -400,6 +409,7 @@ const createAssets = async ({
const pathToUidMap: Record<string, string> = {}; // Path to UID mapping
const seenFilenames = new Map<string, { uid: string; metadata: any; blobPath: string }>();
const pathToFilenameMap = new Map<string, string>();
const usedAssetUids = new Set<string>();

// Discover assets and deduplicate by filename
for await (const fileName of read(assetsDir)) {
Expand Down Expand Up @@ -429,7 +439,25 @@ const createAssets = async ({
pathToFilenameMap.set(value, filename);
// Only create asset ONCE per unique filename
if (!seenFilenames?.has(filename)) {
const uid = uuidv4?.()?.replace?.(/-/g, '');
// Derive a uid that is stable across export runs so delta
// dedupe (asset-metadata/uid-mapper lookups) can match
// prior iterations: jcr:uuid β†’ hashed DAM path β†’ random.
const jcrUuid = parseData?._raw?.assetNode?.['jcr:uuid'];
let uid =
typeof jcrUuid === 'string' && jcrUuid.trim() !== ''
? jcrUuid.replace(/-/g, '').toLowerCase()
: '';
if (!uid || usedAssetUids.has(uid)) {
const assetPath = parseData?.asset?.path;
uid =
typeof assetPath === 'string' && assetPath.trim() !== ''
? createHash('sha256').update(assetPath).digest('hex').slice(0, 32)
: '';
}
if (!uid || usedAssetUids.has(uid)) {
uid = uuidv4?.()?.replace?.(/-/g, '');
}
usedAssetUids.add(uid);
const blobPath = firstJson?.replace?.('.metadata.json', '');

seenFilenames?.set(filename, {
Expand Down Expand Up @@ -1063,6 +1091,38 @@ function processFieldsRecursive(
const uid = getLastKey(field?.contentstackFieldUid);
const actualUid = getActualFieldUid(uid, field?.uid);

const isAemComponentFallback =
typeof field?.otherCmsType === 'string' && field.otherCmsType.includes('/components/');
const valueLooksLikeAemComponent =
value && typeof value === 'object' && !Array.isArray(value) && ':type' in (value as any);

// An unconfigured AEM folder node (nt:folder) is an empty placeholder, not
// real content. Leaking it as a raw {":type":"nt:folder"} object corrupts a
// field that is also mapped as a reference under the same uid, which then
// crashes the import audit-fix ("entry.map is not a function"). Emit null.
if (value && typeof value === 'object' && (value as any)?.[':type'] === 'nt:folder') {
obj[actualUid] = null;
break;
}

// A 'json' field is always created as a JSON-RTE in the content-type schema
// (allow_json_rte + rich_text_type: "advanced"), so the importer walks this
// value as an RTE document (gatherJsonRteAssetIds -> value.children.forEach).
// A raw AEM component object (e.g. {":type": "..."}) or an array has no
// `children`, so leaking it here crashes the import with
// "Cannot read properties of undefined (reading 'forEach')". Keep the value
// only if it is already a valid JSON-RTE doc; otherwise emit null (the
// importer safely skips null/falsy RTE values).
if (isAemComponentFallback || valueLooksLikeAemComponent) {
const isValidJsonRte =
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Array.isArray((value as any).children);
obj[actualUid] = isValidJsonRte ? value : null;
break;
}

let htmlContent = '';

if (typeof value === 'string') {
Expand Down Expand Up @@ -1301,6 +1361,7 @@ const createEntry = async ({
const entriesData: Record<string, Record<string, any[]>> = {};
const allLocales: object = { ...project?.master_locale, ...project?.locales };
const entryMapping: Record<string, string[]> = {};
const usedEntryUids = new Set<string>();

// Process each entry file
for await (const fileName of read(entriesDir)) {
Expand All @@ -1310,12 +1371,33 @@ const createEntry = async ({
}
const content: unknown = await fs.promises.readFile(filePath, 'utf-8');
if (typeof content === 'string') {
const uid = uuidv4?.()?.replace?.(/-/g, '');
const parseData = JSON.parse(content);
// Use the page model's stable "id" as the entry uid so uid-mapper keys
// stay consistent across delta iterations; random uuid only as fallback.
let modelId = typeof parseData?.id === 'string' && parseData.id.trim() !== ''
? uidCorrector(parseData.id)
: '';
// Template-based entries (experience fragments like xf-web-variation, and
// pages like content-page) carry no stable page "id"; derive a stable uid
// from title + templateType (or just templateType when there's no title)
// so they track across iterations (must match extractEntries in
// upload-api's migration-aem).
if (!modelId && parseData?.templateType) {
modelId = parseData?.title
? uidCorrector(`${parseData.title}_${parseData.templateType}`)
: uidCorrector(parseData.templateType);
}
const uid = modelId && !usedEntryUids.has(modelId)
? modelId
: uuidv4?.()?.replace?.(/-/g, '');
usedEntryUids.add(uid);
const title = getTitle(parseData);
const isEFragment = isExperienceFragment(parseData);
const templateUid = isEFragment?.isXF ? parseData?.title : parseData?.templateName ?? parseData?.templateType;
const contentType = (contentTypes as ContentType[] | undefined)?.find?.((element) => element?.otherCmsUid === templateUid);
let contentType = (contentTypes as ContentType[] | undefined)?.find?.((element) => element?.otherCmsUid === templateUid);
if (!contentType && parseData?.title) {
contentType = (contentTypes as ContentType[] | undefined)?.find?.((element) => element?.otherCmsUid === parseData?.title);
}
const locale = getCurrentLocale(parseData);
const mappedLocale = locale ? getLocaleFromMapper(allLocales as Record<string, string>, locale) : Object?.keys?.(project?.master_locale ?? {})?.[0];
const items = parseData?.[':items']?.root?.[':items'];
Expand Down Expand Up @@ -1343,6 +1425,18 @@ const createEntry = async ({
);
addEntryToEntriesData(entriesData, resolvedCtUid, data, mappedLocale);
addUidToEntryMapping(entryMapping, resolvedCtUid, uid);
} else {
const reason = !contentType?.contentstackUid
? `no content type matched (templateUid="${templateUid}", title="${parseData?.title}")`
: !mappedLocale
? `no mapped locale for "${locale}" (available: ${Object.values(allLocales as Record<string, string>).join(', ') || 'none'})`
: 'no entry data produced';
await customLogger(
projectId,
destinationStackId,
'warn',
getLogMessage(srcFunc, `Skipped entry from "${fileName}": ${reason}.`, {})
);
}
}
}
Expand Down
Loading
Loading