Skip to content
Merged

Dev #1084

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
32 changes: 31 additions & 1 deletion api/src/services/migration.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
import { aemService } from './aem.service.js';
import { requestWithSsoTokenRefresh } from '../utils/sso-request.utils.js';
import { utilsUpdateCli } from './updateEntryCli.service.js';
import { enrichConfigWithAssetMapping, removeEntriesFromDatabase } from '../utils/entry-update.utils.js';
import { clearStaleEntries, enrichConfigWithAssetMapping, removeEntriesFromDatabase } from '../utils/entry-update.utils.js';
import { removeExistingAssets, saveAssetMetadata } from '../utils/asset-update.utils.js';

/**
Expand Down Expand Up @@ -427,6 +427,20 @@ const startTestMigration = async (req: Request): Promise<any> => {
};

await copyLogsToTestStack(project?.current_test_stack_id, loggerPath);
// Clear any stale entries from a previous run before re-transforming, so orphaned
// chunk files cannot clobber this run's entry data during the update step.
clearStaleEntries(project?.current_test_stack_id, loggerPath);
// fieldAttacher uses destinationStackId as a path segment when writing content-type
// files. Confirm the sanitized stack id resolves inside the migration-data base before
// passing it in, so request-derived input cannot escape via path traversal.
const testMigrationDataBase = path.resolve(
process.cwd(),
MIGRATION_DATA_CONFIG.DATA
);
assertResolvedPathUnderBase(
testMigrationDataBase,
path.join(testMigrationDataBase, safeTestStackId)
);
const contentTypes = await fieldAttacher({
orgId,
projectId: safeTestProjectId,
Expand Down Expand Up @@ -849,6 +863,22 @@ const startMigration = async (req: Request): Promise<any> => {

await copyLogsToStack(project?.destination_stack_id, loggerPath);

// Clear any stale entries from a previous run before re-transforming, so orphaned
// chunk files cannot clobber this run's entry data during the update step.
clearStaleEntries(project?.destination_stack_id, loggerPath);

// fieldAttacher uses destinationStackId as a path segment when writing content-type
// files. Confirm the sanitized stack id resolves inside the migration-data base before
// passing it in, so request-derived input cannot escape via path traversal.
const finalMigrationDataBase = path.resolve(
process.cwd(),
MIGRATION_DATA_CONFIG.DATA
);
assertResolvedPathUnderBase(
finalMigrationDataBase,
path.join(finalMigrationDataBase, safeFinalStackId)
);

const contentTypes = await fieldAttacher({
orgId,
projectId: safeFinalProjectId,
Expand Down
3 changes: 1 addition & 2 deletions api/src/services/runCli.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ interface TestStack {
isMigrated: boolean;
}
import { setBasicAuthConfig, setOAuthConfig } from '../utils/config-handler.util.js';
import getUidMapperDb from '../models/uidMapper.js';
import customLogger from '../utils/custom-logger.utils.js';
import writeUidMapping from '../utils/uid-mapper.utils.js';

/**
* Determines log level based on message content without removing ANSI codes
Expand Down
65 changes: 63 additions & 2 deletions api/src/utils/entry-update.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ProjectModelLowdb from "../models/project-lowdb.js";
import path from "path";
import fs from "node:fs";
import { MIGRATION_DATA_CONFIG, DATABASE_FILES } from "../constants/index.js";
import { sanitizeStackId, assertResolvedPathUnderBase } from "./sanitize-path.utils.js";

/**
* Helper function to write log entries to file
Expand All @@ -19,6 +20,38 @@ const writeLogEntry = (message: string, methodName: string, loggerPath?: string)
}
};

/**
* Deletes the transformed entries tree for a stack before a fresh import.
*
* Each import run writes entry chunk files with fresh random UUID names and overwrites
* index.json, but never removes the previous run's chunk files. Those orphans carry
* stale (previous-iteration) content that can later clobber current data during update.
* Wiping the entries tree up front guarantees the importer starts from a clean slate.
*
* Scope is limited to the `entries/` subtree only β€” assets, references, environments,
* locales and content-type creation (driven by the lowdb mappers, not this folder) are
* untouched.
*/
export const clearStaleEntries = (stackId: string, loggerPath?: string): void => {
const safeStackId = sanitizeStackId(stackId);
if (!safeStackId) {
writeLogEntry(`Invalid stackId, skipping stale entries cleanup.`, "clearStaleEntries", loggerPath);
return;
}

const dataBase = path.resolve(process.cwd(), MIGRATION_DATA_CONFIG.DATA);
const entriesDir = path.join(dataBase, safeStackId, MIGRATION_DATA_CONFIG.ENTRIES_DIR_NAME);
assertResolvedPathUnderBase(dataBase, entriesDir);

if (!fs.existsSync(entriesDir)) {
writeLogEntry(`No existing entries directory to clear: ${entriesDir}`, "clearStaleEntries", loggerPath);
return;
}

fs.rmSync(entriesDir, { recursive: true, force: true });
writeLogEntry(`Cleared stale entries directory before import: ${entriesDir}`, "clearStaleEntries", loggerPath);
};

export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: string): Promise<string | null> => {
const entriesToUpdate: Record<string, Record<string, any>> = {};

Expand Down Expand Up @@ -72,8 +105,36 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?:

for (const localeDir of localeDirs) {
const localePath = path.join(ctPath, localeDir.name);
const jsonFiles = fs.readdirSync(localePath)
?.filter((file) => file?.endsWith(".json") && file !== "index.json");
// Respect index.json β€” only the chunk files it lists are current. Each import run
// writes chunk files with fresh random UUID names and overwrites index.json, but does
// not delete prior runs' chunk files. Globbing all *.json would pick up those orphans,
// whose stale (previous-iteration) content can then clobber the current entry data.
const indexPath = path.join(localePath, MIGRATION_DATA_CONFIG.ENTRIES_MASTER_FILE);
let jsonFiles: string[];
if (fs.existsSync(indexPath)) {
// Guard against a missing/corrupt/non-object index.json β€” a parse failure or
// unexpected shape here would otherwise throw and abort the entire removal step.
let indexData: unknown;
try {
indexData = JSON.parse(fs.readFileSync(indexPath, "utf-8"));
} catch (err) {
writeLogEntry(`Failed to parse index.json at ${indexPath}, skipping locale: ${(err as Error)?.message}`, "removeEntriesFromDatabase", loggerPath);
continue;
}
if (!indexData || typeof indexData !== "object") {
writeLogEntry(`index.json at ${indexPath} is not an object, skipping locale.`, "removeEntriesFromDatabase", loggerPath);
continue;
}
jsonFiles = Object.values(indexData as Record<string, unknown>)
// Sanitize to path.basename β€” index values are trusted verbatim otherwise,
// so an unexpected value could introduce extra path segments.
.filter((file): file is string => typeof file === "string" && file.endsWith(".json"))
.map((file) => path.basename(file));
} else {
// Legacy data without an index.json β€” fall back to globbing.
jsonFiles = fs.readdirSync(localePath)
?.filter((file) => file?.endsWith(".json") && file !== MIGRATION_DATA_CONFIG.ENTRIES_MASTER_FILE);
}

for (const jsonFile of jsonFiles) {
const filePath = path.join(localePath, jsonFile);
Expand Down
18 changes: 13 additions & 5 deletions api/src/utils/field-attacher.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ import getContentTypesMapperDb from "../models/contentTypesMapper-lowdb.js";
import getFieldMapperDb from "../models/FieldMapper.js";
import { contenTypeMaker } from "./content-type-creator.utils.js";
import { shouldSkipContentTypeCreation } from "./content-type-checker.utils.js";
import { sanitizeProjectId } from "./sanitize-path.utils.js";
import { sanitizeProjectId, sanitizeStackId } from "./sanitize-path.utils.js";
import customLogger from "./custom-logger.utils.js";

export const fieldAttacher = async ({ projectId, orgId, destinationStackId, region, user_id, is_sso }: any) => {
const safeProjectId = sanitizeProjectId(projectId);
if (!safeProjectId) {
throw new Error("Invalid project identifier");
}
// Re-sanitize the destination stack id here as well: it is used as a path segment
// downstream (contenTypeMaker -> writeFile), so it must be validated at the sink's
// entry point to break any path-traversal taint chain regardless of the caller.
const safeDestinationStackId = sanitizeStackId(destinationStackId);
if (!safeDestinationStackId) {
throw new Error("Invalid destination stack identifier");
}
await ProjectModelLowdb.read();
const projectData: any = ProjectModelLowdb.chain.get("projects").find({
id: safeProjectId,
Expand Down Expand Up @@ -38,16 +46,16 @@ export const fieldAttacher = async ({ projectId, orgId, destinationStackId, regi
}

if (iteration === 1) {
await contenTypeMaker({ contentType, destinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso })
await contenTypeMaker({ contentType, destinationStackId: safeDestinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso })

}
else {
const shouldSkip = await shouldSkipContentTypeCreation(safeProjectId, contentType?.otherCmsUid, iteration);
if (!shouldSkip) {
console.info(`Creating new content type: ${contentType.otherCmsUid}`);
await contenTypeMaker({ contentType, destinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso })
await customLogger(safeProjectId, safeDestinationStackId, 'info', `Creating new content type: ${contentType.otherCmsUid}`);
await contenTypeMaker({ contentType, destinationStackId: safeDestinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso })
} else {
console.info(`Skipping content type creation: ${contentType.otherCmsUid} (already exists from previous iteration)`);
await customLogger(safeProjectId, safeDestinationStackId, 'info', `Skipping content type creation: ${contentType.otherCmsUid} (already exists from previous iteration)`);
}
}
contentTypes?.push?.(contentType);
Expand Down
Loading
Loading