Skip to content
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
1ccf777
chervon direction
mirafedas Mar 30, 2026
6920441
removed the connector lines
mirafedas Mar 30, 2026
eace76f
row selection on row click
mirafedas Mar 30, 2026
584b501
Merge branch 'main' into mwpw-190616
mirafedas Mar 31, 2026
9b25ea1
fixed offer data for variations
mirafedas Apr 1, 2026
2c14d89
Merge branch 'main' into mwpw-190616
mirafedas Apr 1, 2026
00f6e19
formatting
mirafedas Apr 3, 2026
b4101cf
Merge branch 'main' into mwpw-190616
mirafedas Apr 3, 2026
46eaf68
Merge branch 'main' into mwpw-190616
mirafedas Apr 13, 2026
1bc7de6
Merge branch 'main' into mwpw-190616
mirafedas Apr 14, 2026
5ddbc84
Merge branch 'main' into mwpw-190616
mirafedas Apr 15, 2026
72a2f5d
Merge remote-tracking branch 'origin/main' into mwpw-190616
mirafedas Apr 20, 2026
f0440ba
MWPW-190616: Add cleanup-variations IO action to remove invalid varia…
mirafedas Apr 20, 2026
9683bd1
MWPW-190616: Add unit tests for cleanup-variations action, fix 429 re…
mirafedas Apr 20, 2026
00a852b
Merge remote-tracking branch 'origin/main' into mwpw-190616
mirafedas Apr 21, 2026
644f28e
MWPW-190616: Refactor cleanup-variations — extract locales to JSON, m…
mirafedas Apr 21, 2026
86b643b
Merge branch 'main' into mwpw-190616
mirafedas Apr 24, 2026
cc70164
Merge branch 'main' into mwpw-190616
mirafedas Apr 24, 2026
a0e6058
Merge branch 'main' into mwpw-190616
mirafedas Apr 27, 2026
e4b867c
Merge branch 'main' into mwpw-190616
mirafedas Apr 28, 2026
38cfbfb
moved locales.json to www
mirafedas Apr 28, 2026
1110d77
Merge branch 'mwpw-190616' of https://github.com/adobecom/mas into mw…
mirafedas Apr 28, 2026
0e339a9
Merge branch 'main' into mwpw-190616
mirafedas Apr 29, 2026
ba83d7f
formatting
mirafedas Apr 29, 2026
efb6750
Merge branch 'main' into mwpw-190616
mirafedas Apr 30, 2026
549d2c0
Merge branch 'main' into mwpw-190616
mirafedas May 1, 2026
54c7175
Removed import, added odin-endpoint
mirafedas May 1, 2026
fff49a8
Merge branch 'main' into mwpw-190616
Axelcureno May 4, 2026
21810c6
Merge branch 'main' into mwpw-190616
mirafedas May 5, 2026
3bc96e7
Merge branch 'main' into mwpw-190616
afmicka May 7, 2026
865880c
Merge branch 'main' into mwpw-190616
mirafedas May 11, 2026
781569b
removed the cleanup variations script
mirafedas May 11, 2026
868b9fb
Merge branch 'mwpw-190616' of https://github.com/adobecom/mas into mw…
mirafedas May 11, 2026
c8023b3
removed localeFromPath and cleanup-variations from scripts
mirafedas May 11, 2026
78c2765
brought back the locales.js
mirafedas May 11, 2026
2e2d921
fixed table styles, dictionnary
mirafedas May 11, 2026
6ada61f
fix: show offer ID for grouped variations in mas-items-selector
mirafedas May 12, 2026
4e188c9
fixed offer id for grouped variations, fixed placeholder modal name
mirafedas May 12, 2026
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
9 changes: 9 additions & 0 deletions io/studio/app.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ application:
annotations:
require-adobe-auth: false
final: true
cleanup-variations:
function: src/cleanup-variations/index.js
web: 'yes'
runtime: nodejs:22
limits:
timeout: 3600000
annotations:
require-adobe-auth: true
final: true
health-check:
function: src/health-check/index.js
web: 'yes'
Expand Down
243 changes: 243 additions & 0 deletions io/studio/src/cleanup-variations/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
'use strict';

const { Core } = require('@adobe/aio-sdk');
const {
fetchFragmentByPath,
fetchOdin,
putToOdin,
processBatchWithConcurrency,
getValues,
localeFromPath,
} = require('../common');
const { ACOM, CCD, EXPRESS, ADOBE_HOME, COMMERCE } = require('../../../www/src/locales.json');

const logger = Core.Logger('cleanup-variations', { level: 'info' });

const DEFAULT_LOCALES = {
acom: ACOM,
'acom-cc': ACOM,
'acom-dc': ACOM,
nala: ACOM,
sandbox: ACOM,
ccd: CCD,
express: EXPRESS,
'adobe-home': ADOBE_HOME,
commerce: COMMERCE,
};

/**
* Returns the set of valid variation locale codes for a given fragment locale on a surface.
* A variation is valid if its locale matches the fragment's default locale entry (including its regions).
*
* Examples (acom):
* getValidVariationsLocales('acom', 'de_DE') → ['de_DE', 'de_AT', 'de_CH', 'de_LU']
* getValidVariationsLocales('acom', 'en_GB') → ['en_GB', 'en_AU', 'en_IN'] (NOT en_US)
* getValidVariationsLocales('acom', 'en_US') → ['en_US', 'en_AE', ...]
*
* @param {string} surface e.g. 'acom'
* @param {string} localeCode e.g. 'de_DE'
* @returns {string[]}
*/
function getValidVariationsLocales(surface, localeCode) {
const [lang, country] = localeCode.split('_');
const locales = DEFAULT_LOCALES[surface];
if (!locales) return [localeCode];

const entry = locales.find((l) => l.lang === lang && l.country === country);
if (!entry) return [localeCode];

const valid = [`${lang}_${entry.country}`];
for (const region of entry.regions ?? []) {
valid.push(`${lang}_${region}`);
}
return valid;
}

/**
* Lists all fragments under a locale folder using cursor-based pagination.
* Uses the same ?path= param as the rest of the codebase, pointed at the folder.
* @param {string} odinEndpoint
* @param {string} surface e.g. 'acom'
* @param {string} locale e.g. 'de_DE'
* @param {string} authToken
* @returns {Promise<string[]>} array of fragment paths
*/
async function listFragmentPaths(odinEndpoint, surface, locale, authToken) {
const folderPath = `/content/dam/mas/${surface}/${locale}`;
const paths = [];
let cursor = null;

do {
const qs = cursor ? `path=${folderPath}&cursor=${encodeURIComponent(cursor)}` : `path=${folderPath}`;
let response;
let delay = 1000;
for (let attempt = 0; attempt <= 3; attempt++) {
response = await fetchOdin(odinEndpoint, `/adobe/sites/cf/fragments?${qs}`, authToken, {
ignoreErrors: [400, 404, 429],
});
if (response.status !== 429) break;
logger.warn(`Rate limited listing ${surface}/${locale}, retrying in ${delay}ms (attempt ${attempt + 1}/3)`);
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= 2;
}
if (!response.ok) break;
const data = await response.json();
for (const item of data.items ?? []) {
if (item.path) paths.push(item.path);
}
cursor = data.cursor ?? null;
} while (cursor);

logger.info(`listFragmentPaths ${surface}/${locale}: ${paths.length} total fragment(s)`);
return paths;
}

/**
* Fetches a fragment by path with exponential backoff retry on 429 rate-limit responses.
* @param {string} odinEndpoint
* @param {string} fragmentPath
* @param {string} authToken
* @param {number} [maxRetries=3]
* @returns {Promise<{ fragment: object|null, status: number, etag: string|null }>}
*/
async function fetchFragmentWithRetry(odinEndpoint, fragmentPath, authToken, maxRetries = 3) {
let delay = 1000;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const result = await fetchFragmentByPath(odinEndpoint, fragmentPath, authToken);
if (result.status !== 429 && result.status !== 500) return result;
if (attempt === maxRetries) return result;
logger.warn(`Rate limited fetching ${fragmentPath}, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= 2;
}
}

/**
* Processes a single fragment path: fetches it, finds invalid variation paths, removes them via PUT.
* Uses PUT (not PATCH) because the variations field is locked by live relationships.
* @param {string} odinEndpoint
* @param {string} fragmentPath e.g. /content/dam/mas/acom/de_DE/some-card
* @param {string} surface e.g. 'acom'
* @param {string} authToken
* @param {boolean} dryRun
* @returns {Promise<{ path: string, removed: string[] }|null>} null if no changes needed
*/
async function processFragmentPath(odinEndpoint, fragmentPath, surface, authToken, dryRun) {
const { fragment, status, etag } = await fetchFragmentWithRetry(odinEndpoint, fragmentPath, authToken);
if (status !== 200 || !fragment) {
logger.warn(`Fragment not found at ${fragmentPath}: ${status}`);
return null;
}

const variationsField = getValues(fragment, 'variations');
if (!variationsField?.values?.length) return null;

const locale = localeFromPath(fragmentPath);
if (!locale) {
logger.warn(`Could not parse locale from path ${fragmentPath}`);
return null;
}

const validLocales = getValidVariationsLocales(surface, locale);
const allVariationPaths = variationsField.values;
const validPaths = allVariationPaths.filter((vPath) => validLocales.includes(localeFromPath(vPath)));
const removedPaths = allVariationPaths.filter((vPath) => !validLocales.includes(localeFromPath(vPath)));

if (!removedPaths.length) return null;

logger.info(
`${dryRun ? '[dry-run] ' : ''}Fragment ${fragmentPath}: removing ${removedPaths.length} variation(s): ${removedPaths.join(', ')}`,
);

if (!dryRun) {
// Variations field is locked by live relationships — must use PUT with full fragment
const updatedFields = fragment.fields.map((field) =>
field.name === 'variations' ? { ...field, values: validPaths } : field,
);
const result = await putToOdin(odinEndpoint, fragment.id, authToken, {
title: fragment.title,
description: fragment.description || '',
fields: updatedFields,
etag,
});
if (!result.success) {
throw new Error(`PUT failed for ${fragmentPath}: ${result.error}`);
}
}

return { path: fragmentPath, removed: removedPaths };
}

/**
* Main action entry point.
*
* POST body (all optional — omit to process everything):
* surface: string — limit to one surface (e.g. 'acom')
* locale: string — limit to one locale (e.g. 'de_DE')
* dryRun: boolean — default true; set false to apply changes
*/
async function main(params) {
const authToken = params.__ow_headers?.authorization?.replace(/^Bearer\s+/i, '');
if (!authToken) {
return { statusCode: 401, body: { error: 'Missing Authorization header' } };
}

const odinEndpoint = params.odinEndpoint;
if (!odinEndpoint) {
return { statusCode: 500, body: { error: 'Missing odinEndpoint configuration' } };
}

const { surface: targetSurface, locale: targetLocale, dryRun = true } = params;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSRF risk — odinEndpoint should come from config, not the request body

Every other action in io/studio gets odinEndpoint from $ODIN_ENDPOINT injected via app.config.yaml inputs. Accepting it from the POST body lets any authenticated caller point this action at an arbitrary AEM host.

Fix: add to app.config.yaml under cleanup-variations:

inputs:
    LOG_LEVEL: debug
    odinEndpoint: $ODIN_ENDPOINT

The value arrives in params.odinEndpoint the same way — no read-side change needed — but the caller can no longer override it.


if (targetSurface && !DEFAULT_LOCALES[targetSurface]) {
return { statusCode: 400, body: { error: `Unknown surface: ${targetSurface}` } };
}

const surfaces = targetSurface ? [targetSurface] : Object.keys(DEFAULT_LOCALES);
const summary = { processed: 0, removed: 0, errors: [], dryRun, details: [] };

for (const surface of surfaces) {
const allLocales = DEFAULT_LOCALES[surface]
.filter((l) => !(l.lang === 'en' && l.country === 'US'))
.map((l) => `${l.lang}_${l.country}`);
const locales = targetLocale ? [targetLocale] : allLocales;

logger.info(`Surface '${surface}': processing ${locales.length} locale(s)`);

for (const locale of locales) {
logger.info(`Listing fragments for ${surface}/${locale}`);
let fragmentPaths;
try {
fragmentPaths = await listFragmentPaths(odinEndpoint, surface, locale, authToken);
} catch (err) {
logger.error(`Failed to list fragments for ${surface}/${locale}: ${err.message}`);
summary.errors.push({ surface, locale, error: err.message });
continue;
}

logger.info(`Found ${fragmentPaths.length} fragment(s) in ${surface}/${locale}`);

const results = await processBatchWithConcurrency(fragmentPaths, 3, async (fragmentPath) => {
try {
return await processFragmentPath(odinEndpoint, fragmentPath, surface, authToken, dryRun);
} catch (err) {
logger.error(`Error processing ${fragmentPath}: ${err.message}`);
summary.errors.push({ fragmentPath, error: err.message });
return null;
}
});

for (const result of results) {
summary.processed++;
if (result) {
summary.removed += result.removed.length;
summary.details.push(result);
}
}
}
}

return { statusCode: 200, body: summary };
}

module.exports = { main, getValidVariationsLocales };
6 changes: 6 additions & 0 deletions io/studio/src/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ function getTargetPath(path, locale) {
return `/content/dam/mas/${surface}/${locale}/${fragmentPath}`;
}

function localeFromPath(path) {
return path?.match(PATH_TOKENS)?.groups?.parsedLocale ?? null;
}

async function postToOdinWithRetry(odinEndpoint, URI, authToken, payload, maxRetries = 3) {
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
Expand Down Expand Up @@ -392,6 +396,8 @@ async function deleteFragmentById(odinEndpoint, fragmentId, authToken, etag) {

module.exports = {
DEFAULT_PACKAGE_NAME,
Comment thread
Axelcureno marked this conversation as resolved.
PATH_TOKENS,
localeFromPath,
buildSiblingActionName,
createRuntimeClient,
fetchFragmentByPath,
Expand Down
Loading
Loading