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 | 3 | import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js'; |
| 4 | 4 | import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js'; |
| 5 | 5 | import { renderTemplate, renderTemplateAsync } from './templates.js'; |
| 6 | 6 | import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js'; |
| 7 | 7 | import { getContext } from './st-context.js'; |
| 8 | 8 | import { isAdmin } from './user.js'; |
| 9 | 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 | 1855 | * Writes a field to the character's data extensions object. |
| 1844 | 1856 | * @param {number|string} characterId Index in the character array |
| 1845 | 1857 | * @param {string} key Field name |
| @@ -1853,13 +1865,23 @@ export async function writeExtensionField(characterId, key, value) { | ||
| 1853 | 1865 | console.warn('Character not found', characterId); |
| 1854 | 1866 | return; |
| 1855 | 1867 | } |
| 1856 | 1868 | const pathextensionPath = `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 | 1877 | // Process JSON data |
| 1860 | 1878 | if (character.json_data) { |
| 1861 | 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 | 1885 | character.json_data = JSON.stringify(jsonData); |
| 1864 | 1886 | |
| 1865 | 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 | 2015 | * Prompts the user to enter the Git URL of the extension to import. |
| 1893 | 2016 | * After obtaining the Git URL, makes a POST request to '/api/extensions/install' to import the extension. |
| 1894 | 2017 | * If the extension is imported successfully, a success message is displayed. |
| @@ -77,7 +77,9 @@ import { | ||
| 77 | 77 | renderExtensionTemplate, |
| 78 | 78 | renderExtensionTemplateAsync, |
| 79 | 79 | saveMetadataDebounced, |
| 80 | + UNSET_VALUE, | |
| 80 | 81 | writeExtensionField, |
| 82 | + writeExtensionFieldBulk, | |
| 81 | 83 | } from './extensions.js'; |
| 82 | 84 | import { groups, openGroupChat, selected_group, unshallowGroupMembers } from './group-chats.js'; |
| 83 | 85 | import { addLocaleData, getCurrentLocale, t, translate } from './i18n.js'; |
| @@ -202,6 +204,7 @@ export function getContext() { | ||
| 202 | 204 | generateRaw, |
| 203 | 205 | generateRawData, |
| 204 | 206 | writeExtensionField, |
| 207 | + writeExtensionFieldBulk, | |
| 205 | 208 | getThumbnailUrl, |
| 206 | 209 | selectCharacterById, |
| 207 | 210 | messageFormatting, |
| @@ -296,6 +299,9 @@ export function getContext() { | ||
| 296 | 299 | symbols: { |
| 297 | 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 | 2082 | * Flashes the given HTML element via CSS flash animation for a defined period |
| 2066 | 2083 | * @param {JQuery<HTMLElement>} element - The element to flash |
| 2067 | 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 | 13 | import storage from 'node-persist'; |
| 14 | 14 | |
| 15 | 15 | import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js'; |
| 16 | 16 | import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction, forbiddenRegExp } from '../middleware/validateFileName.js'; |
| 17 | 17 | import { deepMerge, humanizedDateTime, tryParse, MemoryLimitedMap, getConfigValue, mutateJsonString, clientRelativePath, getUniqueName, sanitizeSafeCharacterReplacements } from '../util.js'; |
| 18 | 18 | import { TavernCardValidator } from '../validator/TavernCardValidator.js'; |
| 19 | 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 | + * Sentinel value that signals a field should be completely removed (unset) | |
| 1224 | + * from the character card rather than being set to any value. Use this in | |
| 1225 | + * the merge payload wherever a key should be deleted. | |
| 1226 | + * | |
| 1227 | + * Both the server and the frontend share this constant so that callers can | |
| 1228 | + * explicitly opt into deletion without overloading `null`. | |
| 1229 | + * @type {string} | |
| 1230 | + */ | |
| 1231 | +const UNSET_SENTINEL = '__@@UNSET@@__'; | |
| 1232 | + | |
| 1233 | +/** Maximum number of characters processed in parallel during bulk merge */ | |
| 1234 | +const BULK_MERGE_CONCURRENCY = 10; | |
| 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); | |
| 1266 | + if (!pngStringData) { | |
| 1267 | + return { ok: false, error: 'Invalid character file' }; | |
| 1268 | + } | |
| 1269 | + | |
| 1270 | + let character = JSON.parse(pngStringData); | |
| 1271 | + | |
| 1272 | + if (typeof shouldSkip === 'function' && shouldSkip(character)) { | |
| 1273 | + return { ok: false, skipped: true }; | |
| 1274 | + } | |
| 1275 | + | |
| 1276 | + const update = _.cloneDeep(updateData); | |
| 1277 | + _.unset(update, 'json_data'); | |
| 1278 | + _.unset(character, 'json_data'); | |
| 1279 | + | |
| 1280 | + character = deepMerge(character, update); | |
| 1281 | + processUnsetSentinels(character, update); | |
| 1282 | + | |
| 1283 | + const validator = new TavernCardValidator(character); | |
| 1284 | + //Accept either V1 or V2. | |
| 1285 | + if (!validator.validate()) { | |
| 1286 | + return { ok: false, error: validator.lastValidationError ?? 'Validation failed' }; | |
| 1287 | + } | |
| 1288 | + | |
| 1289 | + const targetImg = avatar.replace('.png', ''); | |
| 1290 | + await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request); | |
| 1291 | + return { ok: true }; | |
| 1292 | +} | |
| 1293 | + | |
| 1294 | +/** | |
| 1223 | 1295 | * Handle a POST request to edit character properties. |
| 1224 | 1296 | * |
| 1225 | 1297 | * MergesOperates thein requesttwo bodymodes withdepending theon selectedthe characterrequest andbody: |
| 1226 | - * validates the result against TavernCard V2 specification. | |
| 1227 | 1298 | * |
| 1228 | - * @param {Object} request - The HTTP request object. | |
| 1299 | + * **Single mode** (default behavior) — when `avatar` (string) is present: | |
| 1229 | - * @param {Object} response - The HTTP response object. | |
| 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. | |
| 1230 | 1311 | * |
| 1312 | + * @param {import("express").Request} request - The HTTP request object | |
| 1313 | + * @param {import("express").Response} response - The HTTP response object | |
| 1231 | 1314 | * @returns {void} |
| 1232 | 1315 | * */ |
| 1233 | 1316 | router.post('/merge-attributes', getFileNameValidationFunction('avatar'), async function (request, response) { |
| 1234 | 1317 | try { |
| 1235 | - const update = request.body; | |
| 1318 | + // ── Bulk mode: avatars array is present ────────────────── | |
| 1236 | - const avatarPath = path.join(request.user.directories.characters, update.avatar); | |
| 1319 | + if (Array.isArray(request.body.avatars)) { | |
| 1320 | + const { avatars, data, filter } = request.body; | |
| 1237 | 1321 | |
| 1238 | - const pngStringData = await readCharacterData(avatarPath); | |
| 1322 | + if (!_.isPlainObject(data)) { | |
| 1323 | + return response.status(400).send({ message: 'No valid update data provided.' }); | |
| 1324 | + } | |
| 1239 | 1325 | |
| 1240 | - if (!pngStringData) { | |
| 1326 | + // Determine which avatar files to process | |
| 1241 | - console.error('Error: invalid character file.'); | |
| 1327 | + let targetAvatars; | |
| 1242 | - return response.status(400).send('Error: invalid character file.'); | |
| 1328 | + if (avatars.length > 0) { | |
| 1243 | - } | |
| 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 | + } | |
| 1244 | 1340 | |
| 1245 | - let character = JSON.parse(pngStringData); | |
| 1341 | + const updated = []; | |
| 1342 | + const skipped = []; | |
| 1343 | + const failed = []; | |
| 1246 | 1344 | |
| 1247 | - _.unset(update, 'json_data'); | |
| 1345 | + /** | |
| 1248 | - _.unset(character, 'json_data'); | |
| 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); | |
| 1249 | 1351 | |
| 1250 | - character = deepMerge(character, update); | |
| 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 | + }; | |
| 1251 | 1378 | |
| 1252 | - const validator = new TavernCardValidator(character); | |
| 1379 | + // Process in parallel with a concurrency limit | |
| 1253 | - const targetImg = (update.avatar).replace('.png', ''); | |
| 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); | |
| 1254 | 1391 | |
| 1255 | - //Accept either V1 or V2. | |
| 1392 | + const result = await mergeCharacterUpdate(avatarPath, update.avatar, update, request); | |
| 1256 | 1393 | if (validatorresult.validate()ok) { |
| 1257 | - await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request); | |
| 1258 | 1394 | response.sendStatus(200); |
| 1259 | 1395 | } else { |
| 1260 | 1396 | console.warn(validatorresult.lastValidationErrorerror); |
| 1261 | 1397 | response.status(400).send({ message: `Validation failed for ${characterupdate.nameavatar}`, error: validatorresult.lastValidationErrorerror }); |
| 1262 | 1398 | } |
| 1263 | 1399 | } catch (exception) { |
| 1264 | 1400 | response.status(500).send({ message: 'Unexpected error while saving character.', error: exception.toString() }); |
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | import path from 'node:path'; |
| 2 | 2 | |
| 3 | +export const forbiddenRegExp = path.sep === '/' ? /[/\x00]/ : /[/\x00\\]/; | |
| 4 | + | |
| 3 | 5 | /** |
| 4 | 6 | * Checks if an object has a toString method. |
| 5 | 7 | * @param {object} o Object to check |
| @@ -23,7 +25,6 @@ export function getFileNameValidationFunction(fieldName) { | ||
| 23 | 25 | */ |
| 24 | 26 | return function validateAvatarUrlMiddleware(req, res, next) { |
| 25 | 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 | 28 | if (forbiddenRegExp.test(req.body[fieldName])) { |
| 28 | 29 | console.error('An error occurred while validating the request body', { |
| 29 | 30 | handle: req.user.profile.handle, |