Bulk extension field updates via merge-attributes with UNSET_VALUE sentinel (#5471) * feat: add bulk extension field updates with UNSET_VALUE sentinel for key deletion - Add `UNSET_VALUE` sentinel constant to signal complete field removal from character cards - Add `writeExtensionFieldBulk()` function to update extension fields across multiple characters in a single API call - Add `deleteValueByPath()` utility function to remove nested object keys by dot-path - Update `writeExtensionField()` to support `UNSET_VALUE` for deleting extension keys - Extend `/api/characters/merge-attributes * Revert package-lock.json changes * Allow null values in merge-attributes filter path validation Change filter.path existence check to only skip on undefined, not null. This allows merging attributes when the existing value is explicitly null, treating null as a valid value rather than absence of a value. * fix: share forbiddenRegExp between modules * feat: add writeExtensionFieldBulk and UNSET_VALUE constant to getContext * Update src/endpoints/characters.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: validate for .png extension * Update public/scripts/extensions.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: extract shouldSkip logic as a function param to avoid double parsing --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed| @@ -3,7 +3,7 @@ import { DOMPurify, Popper } from '../lib.js'; | |||
| 3 | import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js'; | 3 | import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js'; |
| 4 | import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js'; | 4 | import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js'; |
| 5 | import { renderTemplate, renderTemplateAsync } from './templates.js'; | 5 | import { renderTemplate, renderTemplateAsync } from './templates.js'; |
| 6 | import { delay, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js'; | 6 | import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js'; |
| 7 | import { getContext } from './st-context.js'; | 7 | import { getContext } from './st-context.js'; |
| 8 | import { isAdmin } from './user.js'; | 8 | import { isAdmin } from './user.js'; |
| 9 | import { addLocaleData, getCurrentLocale, t } from './i18n.js'; | 9 | import { addLocaleData, getCurrentLocale, t } from './i18n.js'; |
| @@ -1840,6 +1840,18 @@ export async function runGenerationInterceptors(chat, contextSize, type) { | |||
| 1840 | } | 1840 | } |
| 1841 | 1841 | ||
| 1842 | /** | 1842 | /** |
| 1843 | * Sentinel value that signals a field should be completely removed (unset) | ||
| 1844 | * from the character card rather than being set to any value. Pass this as | ||
| 1845 | * the `value` argument to {@link writeExtensionField} or | ||
| 1846 | * {@link writeExtensionFieldBulk} to delete the key entirely. | ||
| 1847 | * | ||
| 1848 | * Using `null` as a value will set the field to `null` (the key remains). | ||
| 1849 | * Using this sentinel will delete the key from the character card. | ||
| 1850 | * @type {string} | ||
| 1851 | */ | ||
| 1852 | export const UNSET_VALUE = '__@@UNSET@@__'; | ||
| 1853 | |||
| 1854 | /** | ||
| 1843 | * Writes a field to the character's data extensions object. | 1855 | * Writes a field to the character's data extensions object. |
| 1844 | * @param {number|string} characterId Index in the character array | 1856 | * @param {number|string} characterId Index in the character array |
| 1845 | * @param {string} key Field name | 1857 | * @param {string} key Field name |
| @@ -1853,13 +1865,23 @@ export async function writeExtensionField(characterId, key, value) { | |||
| 1853 | console.warn('Character not found', characterId); | 1865 | console.warn('Character not found', characterId); |
| 1854 | return; | 1866 | return; |
| 1855 | } | 1867 | } |
| 1856 | const path = `data.extensions.${key}`; | 1868 | const extensionPath = `data.extensions.${key}`; |
| 1857 | setValueByPath(character, path, value); | 1869 | const isUnset = value === UNSET_VALUE; |
| 1870 | |||
| 1871 | if (isUnset) { | ||
| 1872 | deleteValueByPath(character, extensionPath); | ||
| 1873 | } else { | ||
| 1874 | setValueByPath(character, extensionPath, value); | ||
| 1875 | } | ||
| 1858 | 1876 | ||
| 1859 | // Process JSON data | 1877 | // Process JSON data |
| 1860 | if (character.json_data) { | 1878 | if (character.json_data) { |
| 1861 | const jsonData = JSON.parse(character.json_data); | 1879 | const jsonData = JSON.parse(character.json_data); |
| 1862 | setValueByPath(jsonData, path, value); | 1880 | if (isUnset) { |
| 1881 | deleteValueByPath(jsonData, extensionPath); | ||
| 1882 | } else { | ||
| 1883 | setValueByPath(jsonData, extensionPath, value); | ||
| 1884 | } | ||
| 1863 | character.json_data = JSON.stringify(jsonData); | 1885 | character.json_data = JSON.stringify(jsonData); |
| 1864 | 1886 | ||
| 1865 | // Make sure the data doesn't get lost when saving the current character | 1887 | // Make sure the data doesn't get lost when saving the current character |
| @@ -1889,6 +1911,107 @@ export async function writeExtensionField(characterId, key, value) { | |||
| 1889 | } | 1911 | } |
| 1890 | 1912 | ||
| 1891 | /** | 1913 | /** |
| 1914 | * @typedef {object} BulkExtensionFieldResult | ||
| 1915 | * @property {string[]} updated Avatar filenames that were successfully updated | ||
| 1916 | * @property {string[]} skipped Avatar filenames skipped (filter didn't match or unreadable) | ||
| 1917 | * @property {string[]} failed Avatar filenames where the update failed | ||
| 1918 | */ | ||
| 1919 | |||
| 1920 | /** | ||
| 1921 | * Writes (or deletes) an extension field for multiple characters in a single | ||
| 1922 | * bulk request. Unlike {@link writeExtensionField}, this sends one API call | ||
| 1923 | * for all characters, and the server processes them in parallel. | ||
| 1924 | * | ||
| 1925 | * When `value` is {@link UNSET_VALUE} the extension key is **deleted** from | ||
| 1926 | * each matching character card. Passing `null` sets the field to `null` | ||
| 1927 | * (the key is preserved). | ||
| 1928 | * | ||
| 1929 | * @param {string[]|null} avatars Avatar filenames to update. Pass `null` or an | ||
| 1930 | * empty array to target **all** characters in the user's character directory. | ||
| 1931 | * @param {string} key Extension field name (e.g. "greeting_tools") | ||
| 1932 | * @param {any} value Field value, `null` to set null, or | ||
| 1933 | * {@link UNSET_VALUE} to delete the key entirely | ||
| 1934 | * @param {object} [options={}] Optional settings | ||
| 1935 | * @param {string} [options.filterPath] Dot-path filter — the server will only | ||
| 1936 | * update characters where this path is present and not `undefined`; | ||
| 1937 | * `null` still counts as a match. Useful when the frontend has shallow | ||
| 1938 | * character data and cannot pre-filter. | ||
| 1939 | * Defaults to `data.extensions.<key>` when unsetting, so deletion requests | ||
| 1940 | * automatically skip characters where the field is missing/`undefined`. | ||
| 1941 | * @returns {Promise<BulkExtensionFieldResult>} Summary of the bulk operation | ||
| 1942 | */ | ||
| 1943 | export async function writeExtensionFieldBulk(avatars, key, value, { filterPath } = {}) { | ||
| 1944 | const context = getContext(); | ||
| 1945 | const extensionPath = `data.extensions.${key}`; | ||
| 1946 | const isUnset = value === UNSET_VALUE; | ||
| 1947 | |||
| 1948 | // Build the server request | ||
| 1949 | const requestBody = { | ||
| 1950 | avatars: Array.isArray(avatars) && avatars.length > 0 ? avatars : [], | ||
| 1951 | data: { | ||
| 1952 | data: { | ||
| 1953 | extensions: { | ||
| 1954 | [key]: value, | ||
| 1955 | }, | ||
| 1956 | }, | ||
| 1957 | }, | ||
| 1958 | }; | ||
| 1959 | |||
| 1960 | // Default filter: when unsetting, only touch characters that have the field | ||
| 1961 | const resolvedFilterPath = filterPath ?? (isUnset ? extensionPath : undefined); | ||
| 1962 | if (resolvedFilterPath) { | ||
| 1963 | requestBody.filter = { path: resolvedFilterPath }; | ||
| 1964 | } | ||
| 1965 | |||
| 1966 | const mergeResponse = await fetch('/api/characters/merge-attributes', { | ||
| 1967 | method: 'POST', | ||
| 1968 | headers: getRequestHeaders(), | ||
| 1969 | body: JSON.stringify(requestBody), | ||
| 1970 | }); | ||
| 1971 | |||
| 1972 | if (!mergeResponse.ok) { | ||
| 1973 | console.error('Bulk extension field update failed', mergeResponse.statusText); | ||
| 1974 | return { updated: [], skipped: [], failed: [] }; | ||
| 1975 | } | ||
| 1976 | |||
| 1977 | /** @type {BulkExtensionFieldResult} */ | ||
| 1978 | const result = await mergeResponse.json(); | ||
| 1979 | |||
| 1980 | // Sync in-memory character objects for successfully updated characters | ||
| 1981 | const updatedSet = new Set(result.updated); | ||
| 1982 | for (const character of context.characters) { | ||
| 1983 | if (!character || !updatedSet.has(character.avatar)) continue; | ||
| 1984 | |||
| 1985 | if (isUnset) { | ||
| 1986 | deleteValueByPath(character, extensionPath); | ||
| 1987 | } else { | ||
| 1988 | setValueByPath(character, extensionPath, value); | ||
| 1989 | } | ||
| 1990 | |||
| 1991 | // Keep json_data in sync | ||
| 1992 | if (character.json_data) { | ||
| 1993 | const jsonData = JSON.parse(character.json_data); | ||
| 1994 | if (isUnset) { | ||
| 1995 | deleteValueByPath(jsonData, extensionPath); | ||
| 1996 | } else { | ||
| 1997 | setValueByPath(jsonData, extensionPath, value); | ||
| 1998 | } | ||
| 1999 | character.json_data = JSON.stringify(jsonData); | ||
| 2000 | } | ||
| 2001 | } | ||
| 2002 | |||
| 2003 | // If the currently active character was updated, sync the hidden input | ||
| 2004 | if (context.characterId !== undefined) { | ||
| 2005 | const activeChar = context.characters[context.characterId]; | ||
| 2006 | if (activeChar && updatedSet.has(activeChar.avatar) && activeChar.json_data) { | ||
| 2007 | $('#character_json_data').val(activeChar.json_data); | ||
| 2008 | } | ||
| 2009 | } | ||
| 2010 | |||
| 2011 | return result; | ||
| 2012 | } | ||
| 2013 | |||
| 2014 | /** | ||
| 1892 | * Prompts the user to enter the Git URL of the extension to import. | 2015 | * Prompts the user to enter the Git URL of the extension to import. |
| 1893 | * After obtaining the Git URL, makes a POST request to '/api/extensions/install' to import the extension. | 2016 | * After obtaining the Git URL, makes a POST request to '/api/extensions/install' to import the extension. |
| 1894 | * If the extension is imported successfully, a success message is displayed. | 2017 | * If the extension is imported successfully, a success message is displayed. |
| @@ -77,7 +77,9 @@ import { | |||
| 77 | renderExtensionTemplate, | 77 | renderExtensionTemplate, |
| 78 | renderExtensionTemplateAsync, | 78 | renderExtensionTemplateAsync, |
| 79 | saveMetadataDebounced, | 79 | saveMetadataDebounced, |
| 80 | UNSET_VALUE, | ||
| 80 | writeExtensionField, | 81 | writeExtensionField, |
| 82 | writeExtensionFieldBulk, | ||
| 81 | } from './extensions.js'; | 83 | } from './extensions.js'; |
| 82 | import { groups, openGroupChat, selected_group, unshallowGroupMembers } from './group-chats.js'; | 84 | import { groups, openGroupChat, selected_group, unshallowGroupMembers } from './group-chats.js'; |
| 83 | import { addLocaleData, getCurrentLocale, t, translate } from './i18n.js'; | 85 | import { addLocaleData, getCurrentLocale, t, translate } from './i18n.js'; |
| @@ -202,6 +204,7 @@ export function getContext() { | |||
| 202 | generateRaw, | 204 | generateRaw, |
| 203 | generateRawData, | 205 | generateRawData, |
| 204 | writeExtensionField, | 206 | writeExtensionField, |
| 207 | writeExtensionFieldBulk, | ||
| 205 | getThumbnailUrl, | 208 | getThumbnailUrl, |
| 206 | selectCharacterById, | 209 | selectCharacterById, |
| 207 | messageFormatting, | 210 | messageFormatting, |
| @@ -296,6 +299,9 @@ export function getContext() { | |||
| 296 | symbols: { | 299 | symbols: { |
| 297 | ignore: IGNORE_SYMBOL, | 300 | ignore: IGNORE_SYMBOL, |
| 298 | }, | 301 | }, |
| 302 | constants: { | ||
| 303 | unset: UNSET_VALUE, | ||
| 304 | }, | ||
| 299 | }; | 305 | }; |
| 300 | } | 306 | } |
| 301 | 307 | ||
| @@ -2062,6 +2062,23 @@ export function setValueByPath(obj, path, value) { | |||
| 2062 | } | 2062 | } |
| 2063 | 2063 | ||
| 2064 | /** | 2064 | /** |
| 2065 | * Deletes a value from a nested object at the given dot-separated path. | ||
| 2066 | * @param {object} obj Object to delete from | ||
| 2067 | * @param {string} path Dot-separated key path (e.g. "data.extensions.myKey") | ||
| 2068 | */ | ||
| 2069 | export function deleteValueByPath(obj, path) { | ||
| 2070 | const keyParts = path.split('.'); | ||
| 2071 | let current = obj; | ||
| 2072 | for (let i = 0; i < keyParts.length - 1; i++) { | ||
| 2073 | if (!current || typeof current !== 'object') return; | ||
| 2074 | current = current[keyParts[i]]; | ||
| 2075 | } | ||
| 2076 | if (current && typeof current === 'object') { | ||
| 2077 | delete current[keyParts[keyParts.length - 1]]; | ||
| 2078 | } | ||
| 2079 | } | ||
| 2080 | |||
| 2081 | /** | ||
| 2065 | * Flashes the given HTML element via CSS flash animation for a defined period | 2082 | * Flashes the given HTML element via CSS flash animation for a defined period |
| 2066 | * @param {JQuery<HTMLElement>} element - The element to flash | 2083 | * @param {JQuery<HTMLElement>} element - The element to flash |
| 2067 | * @param {number} timespan - A number in milliseconds how the flash should last (default is 2000ms. Multiples of 1000ms work best, as they end with the flash animation being at 100% opacity) | 2084 | * @param {number} timespan - A number in milliseconds how the flash should last (default is 2000ms. Multiples of 1000ms work best, as they end with the flash animation being at 100% opacity) |
| @@ -13,7 +13,7 @@ import { Jimp, JimpMime } from '../jimp.js'; | |||
| 13 | import storage from 'node-persist'; | 13 | import storage from 'node-persist'; |
| 14 | 14 | ||
| 15 | import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js'; | 15 | import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js'; |
| 16 | import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js'; | 16 | import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction, forbiddenRegExp } from '../middleware/validateFileName.js'; |
| 17 | import { deepMerge, humanizedDateTime, tryParse, MemoryLimitedMap, getConfigValue, mutateJsonString, clientRelativePath, getUniqueName, sanitizeSafeCharacterReplacements } from '../util.js'; | 17 | import { deepMerge, humanizedDateTime, tryParse, MemoryLimitedMap, getConfigValue, mutateJsonString, clientRelativePath, getUniqueName, sanitizeSafeCharacterReplacements } from '../util.js'; |
| 18 | import { TavernCardValidator } from '../validator/TavernCardValidator.js'; | 18 | import { TavernCardValidator } from '../validator/TavernCardValidator.js'; |
| 19 | import { parse, read, write } from '../character-card-parser.js'; | 19 | import { parse, read, write } from '../character-card-parser.js'; |
| @@ -1220,45 +1220,181 @@ router.post('/edit-attribute', validateAvatarUrlMiddleware, async function (requ | |||
| 1220 | }); | 1220 | }); |
| 1221 | 1221 | ||
| 1222 | /** | 1222 | /** |
| 1223 | * Handle a POST request to edit character properties. | 1223 | * Sentinel value that signals a field should be completely removed (unset) |
| 1224 | * | 1224 | * from the character card rather than being set to any value. Use this in |
| 1225 | * Merges the request body with the selected character and | 1225 | * the merge payload wherever a key should be deleted. |
| 1226 | * validates the result against TavernCard V2 specification. | ||
| 1227 | * | ||
| 1228 | * @param {Object} request - The HTTP request object. | ||
| 1229 | * @param {Object} response - The HTTP response object. | ||
| 1230 | * | 1226 | * |
| 1231 | * @returns {void} | 1227 | * Both the server and the frontend share this constant so that callers can |
| 1232 | * */ | 1228 | * explicitly opt into deletion without overloading `null`. |
| 1233 | router.post('/merge-attributes', getFileNameValidationFunction('avatar'), async function (request, response) { | 1229 | * @type {string} |
| 1234 | try { | 1230 | */ |
| 1235 | const update = request.body; | 1231 | const UNSET_SENTINEL = '__@@UNSET@@__'; |
| 1236 | const avatarPath = path.join(request.user.directories.characters, update.avatar); | ||
| 1237 | 1232 | ||
| 1238 | const pngStringData = await readCharacterData(avatarPath); | 1233 | /** Maximum number of characters processed in parallel during bulk merge */ |
| 1234 | const BULK_MERGE_CONCURRENCY = 10; | ||
| 1239 | 1235 | ||
| 1236 | /** | ||
| 1237 | * Recursively walks `source` and removes any key from `target` whose | ||
| 1238 | * corresponding value in `source` equals the {@link UNSET_SENTINEL}. | ||
| 1239 | * Called after {@link deepMerge} so that the sentinel gets replaced by | ||
| 1240 | * an actual key deletion. | ||
| 1241 | * @param {object} target The merged character object to clean up | ||
| 1242 | * @param {object} source The original update payload (pre-merge clone) | ||
| 1243 | */ | ||
| 1244 | function processUnsetSentinels(target, source) { | ||
| 1245 | for (const key of Object.keys(source)) { | ||
| 1246 | if (source[key] === UNSET_SENTINEL) { | ||
| 1247 | _.unset(target, key); | ||
| 1248 | } else if (_.isPlainObject(source[key]) && _.isPlainObject(target[key])) { | ||
| 1249 | processUnsetSentinels(target[key], source[key]); | ||
| 1250 | } | ||
| 1251 | } | ||
| 1252 | } | ||
| 1253 | |||
| 1254 | /** | ||
| 1255 | * Reads a character card, applies a merge update (with sentinel-based | ||
| 1256 | * unsetting), validates the result, and writes it back. | ||
| 1257 | * @param {string} avatarPath Full path to the character PNG | ||
| 1258 | * @param {string} avatar Avatar filename (e.g. "char.png") | ||
| 1259 | * @param {object} updateData The merge payload to apply | ||
| 1260 | * @param {import("express").Request} request Express request object | ||
| 1261 | * @param {((data: any) => boolean) | null} [shouldSkip] Optional function to determine if a character should be skipped based on its original data (used for bulk merge filtering) | ||
| 1262 | * @returns {Promise<{ok: boolean, error?: string, skipped?: boolean}>} Result of the merge operation, including any validation error | ||
| 1263 | */ | ||
| 1264 | async function mergeCharacterUpdate(avatarPath, avatar, updateData, request, shouldSkip = null) { | ||
| 1265 | const pngStringData = await readCharacterData(avatarPath); | ||
| 1240 | if (!pngStringData) { | 1266 | if (!pngStringData) { |
| 1241 | console.error('Error: invalid character file.'); | 1267 | return { ok: false, error: 'Invalid character file' }; |
| 1242 | return response.status(400).send('Error: invalid character file.'); | ||
| 1243 | } | 1268 | } |
| 1244 | 1269 | ||
| 1245 | let character = JSON.parse(pngStringData); | 1270 | let character = JSON.parse(pngStringData); |
| 1246 | 1271 | ||
| 1272 | if (typeof shouldSkip === 'function' && shouldSkip(character)) { | ||
| 1273 | return { ok: false, skipped: true }; | ||
| 1274 | } | ||
| 1275 | |||
| 1276 | const update = _.cloneDeep(updateData); | ||
| 1247 | _.unset(update, 'json_data'); | 1277 | _.unset(update, 'json_data'); |
| 1248 | _.unset(character, 'json_data'); | 1278 | _.unset(character, 'json_data'); |
| 1249 | 1279 | ||
| 1250 | character = deepMerge(character, update); | 1280 | character = deepMerge(character, update); |
| 1281 | processUnsetSentinels(character, update); | ||
| 1251 | 1282 | ||
| 1252 | const validator = new TavernCardValidator(character); | 1283 | const validator = new TavernCardValidator(character); |
| 1253 | const targetImg = (update.avatar).replace('.png', ''); | ||
| 1254 | |||
| 1255 | //Accept either V1 or V2. | 1284 | //Accept either V1 or V2. |
| 1256 | if (validator.validate()) { | 1285 | if (!validator.validate()) { |
| 1286 | return { ok: false, error: validator.lastValidationError ?? 'Validation failed' }; | ||
| 1287 | } | ||
| 1288 | |||
| 1289 | const targetImg = avatar.replace('.png', ''); | ||
| 1257 | await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request); | 1290 | await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request); |
| 1291 | return { ok: true }; | ||
| 1292 | } | ||
| 1293 | |||
| 1294 | /** | ||
| 1295 | * Handle a POST request to edit character properties. | ||
| 1296 | * | ||
| 1297 | * Operates in two modes depending on the request body: | ||
| 1298 | * | ||
| 1299 | * **Single mode** (default behavior) — when `avatar` (string) is present: | ||
| 1300 | * Merges the request body with the selected character and validates the | ||
| 1301 | * result against TavernCard V2 specification. | ||
| 1302 | * | ||
| 1303 | * **Bulk mode** — when `avatars` (array) is present: | ||
| 1304 | * Applies the same merge to multiple characters in parallel. Supports: | ||
| 1305 | * - An explicit list of avatars, or all characters when the array is empty | ||
| 1306 | * - An optional server-side `filter` so only characters where a given | ||
| 1307 | * JSON path exists and is non-null are updated | ||
| 1308 | * | ||
| 1309 | * In both modes, any value equal to the sentinel `__@@UNSET@@__` will cause | ||
| 1310 | * that key to be **deleted** from the character card instead of being set. | ||
| 1311 | * | ||
| 1312 | * @param {import("express").Request} request - The HTTP request object | ||
| 1313 | * @param {import("express").Response} response - The HTTP response object | ||
| 1314 | * @returns {void} | ||
| 1315 | */ | ||
| 1316 | router.post('/merge-attributes', getFileNameValidationFunction('avatar'), async function (request, response) { | ||
| 1317 | try { | ||
| 1318 | // ── Bulk mode: avatars array is present ────────────────── | ||
| 1319 | if (Array.isArray(request.body.avatars)) { | ||
| 1320 | const { avatars, data, filter } = request.body; | ||
| 1321 | |||
| 1322 | if (!_.isPlainObject(data)) { | ||
| 1323 | return response.status(400).send({ message: 'No valid update data provided.' }); | ||
| 1324 | } | ||
| 1325 | |||
| 1326 | // Determine which avatar files to process | ||
| 1327 | let targetAvatars; | ||
| 1328 | if (avatars.length > 0) { | ||
| 1329 | for (const avatar of avatars) { | ||
| 1330 | if (typeof avatar !== 'string' || forbiddenRegExp.test(avatar) || path.extname(avatar).toLowerCase() !== '.png') { | ||
| 1331 | return response.status(400).send({ message: `Invalid avatar filename: ${avatar}` }); | ||
| 1332 | } | ||
| 1333 | } | ||
| 1334 | targetAvatars = avatars; | ||
| 1335 | } else { | ||
| 1336 | // Empty array → scan all characters in the directory | ||
| 1337 | const files = fs.readdirSync(request.user.directories.characters); | ||
| 1338 | targetAvatars = files.filter(file => path.extname(file).toLowerCase() === '.png'); | ||
| 1339 | } | ||
| 1340 | |||
| 1341 | const updated = []; | ||
| 1342 | const skipped = []; | ||
| 1343 | const failed = []; | ||
| 1344 | |||
| 1345 | /** | ||
| 1346 | * Process a single character in bulk: read, filter, merge, validate, write. | ||
| 1347 | * @param {string} avatar Avatar filename | ||
| 1348 | */ | ||
| 1349 | const processOne = async (avatar) => { | ||
| 1350 | const avatarPath = path.join(request.user.directories.characters, avatar); | ||
| 1351 | |||
| 1352 | try { | ||
| 1353 | /** @type {(character: object) => boolean} */ | ||
| 1354 | let shouldSkip = () => false; | ||
| 1355 | |||
| 1356 | // Apply optional server-side filter before updating the card | ||
| 1357 | if (filter && typeof filter.path === 'string') { | ||
| 1358 | shouldSkip = (character) => { | ||
| 1359 | const value = _.get(character, filter.path); | ||
| 1360 | return value === undefined; | ||
| 1361 | }; | ||
| 1362 | } | ||
| 1363 | |||
| 1364 | const result = await mergeCharacterUpdate(avatarPath, avatar, data, request, shouldSkip); | ||
| 1365 | if (result.ok) { | ||
| 1366 | updated.push(avatar); | ||
| 1367 | } else if (result.skipped) { | ||
| 1368 | skipped.push(avatar); | ||
| 1369 | } else { | ||
| 1370 | console.warn(`Bulk merge failed for ${avatar}:`, result.error); | ||
| 1371 | failed.push(avatar); | ||
| 1372 | } | ||
| 1373 | } catch (error) { | ||
| 1374 | console.error(`Bulk merge failed for ${avatar}:`, error); | ||
| 1375 | failed.push(avatar); | ||
| 1376 | } | ||
| 1377 | }; | ||
| 1378 | |||
| 1379 | // Process in parallel with a concurrency limit | ||
| 1380 | for (let i = 0; i < targetAvatars.length; i += BULK_MERGE_CONCURRENCY) { | ||
| 1381 | const batch = targetAvatars.slice(i, i + BULK_MERGE_CONCURRENCY); | ||
| 1382 | await Promise.allSettled(batch.map(processOne)); | ||
| 1383 | } | ||
| 1384 | |||
| 1385 | return response.send({ updated, skipped, failed }); | ||
| 1386 | } | ||
| 1387 | |||
| 1388 | // ── Single mode (default behavior) ─────────────────────── | ||
| 1389 | const update = request.body; | ||
| 1390 | const avatarPath = path.join(request.user.directories.characters, update.avatar); | ||
| 1391 | |||
| 1392 | const result = await mergeCharacterUpdate(avatarPath, update.avatar, update, request); | ||
| 1393 | if (result.ok) { | ||
| 1258 | response.sendStatus(200); | 1394 | response.sendStatus(200); |
| 1259 | } else { | 1395 | } else { |
| 1260 | console.warn(validator.lastValidationError); | 1396 | console.warn(result.error); |
| 1261 | response.status(400).send({ message: `Validation failed for ${character.name}`, error: validator.lastValidationError }); | 1397 | response.status(400).send({ message: `Validation failed for ${update.avatar}`, error: result.error }); |
| 1262 | } | 1398 | } |
| 1263 | } catch (exception) { | 1399 | } catch (exception) { |
| 1264 | response.status(500).send({ message: 'Unexpected error while saving character.', error: exception.toString() }); | 1400 | response.status(500).send({ message: 'Unexpected error while saving character.', error: exception.toString() }); |
| @@ -1,5 +1,7 @@ | |||
| 1 | import path from 'node:path'; | 1 | import path from 'node:path'; |
| 2 | 2 | ||
| 3 | export const forbiddenRegExp = path.sep === '/' ? /[/\x00]/ : /[/\x00\\]/; | ||
| 4 | |||
| 3 | /** | 5 | /** |
| 4 | * Checks if an object has a toString method. | 6 | * Checks if an object has a toString method. |
| 5 | * @param {object} o Object to check | 7 | * @param {object} o Object to check |
| @@ -23,7 +25,6 @@ export function getFileNameValidationFunction(fieldName) { | |||
| 23 | */ | 25 | */ |
| 24 | return function validateAvatarUrlMiddleware(req, res, next) { | 26 | return function validateAvatarUrlMiddleware(req, res, next) { |
| 25 | if (req.body && fieldName in req.body && (typeof req.body[fieldName] === 'string' || hasToString(req.body[fieldName]))) { | 27 | if (req.body && fieldName in req.body && (typeof req.body[fieldName] === 'string' || hasToString(req.body[fieldName]))) { |
| 26 | const forbiddenRegExp = path.sep === '/' ? /[/\x00]/ : /[/\x00\\]/; | ||
| 27 | if (forbiddenRegExp.test(req.body[fieldName])) { | 28 | if (forbiddenRegExp.test(req.body[fieldName])) { |
| 28 | console.error('An error occurred while validating the request body', { | 29 | console.error('An error occurred while validating the request body', { |
| 29 | handle: req.user.profile.handle, | 30 | handle: req.user.profile.handle, |