From 250e09a4588d62b10ce77a9a200513e18b9b0518 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 26 Mar 2026 10:52:52 -0700 Subject: [PATCH 1/2] LABKEY.Utils.encodeFormName --- src/labkey/Utils.spec.ts | 28 ++++++++++++++++++++++++++++ src/labkey/Utils.ts | 13 +++++++++++++ src/labkey/query/Rows.spec.ts | 22 ++++++++++++++++++---- src/labkey/query/Rows.ts | 30 ++++++++++++++---------------- 4 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/labkey/Utils.spec.ts b/src/labkey/Utils.spec.ts index 8982abb5..e025d0cd 100644 --- a/src/labkey/Utils.spec.ts +++ b/src/labkey/Utils.spec.ts @@ -44,6 +44,34 @@ describe('caseInsensitiveEquals', () => { }); }); +describe('encodeFormName', () => { + test('empty', () => { + expect(Utils.encodeFormName(null)).toBeNull(); + expect(Utils.encodeFormName(undefined)).toBeUndefined(); + expect(Utils.encodeFormName('')).toBe(''); + expect(Utils.encodeFormName(' ')).toBe(' '); + }); + + test('no relevant special character', () => { + expect(Utils.encodeFormName('a')).toBe('a'); + expect(Utils.encodeFormName('$')).toBe('$'); + expect(Utils.encodeFormName('9')).toBe('9'); + expect(Utils.encodeFormName('[a]')).toBe('[a]'); + }); + + test('encoded', () => { + expect(Utils.encodeFormName('"')).toBe('%_%22'); + expect(Utils.encodeFormName('%')).toBe('%_%25'); + expect(Utils.encodeFormName('%_beep')).toBe('%_%25_beep'); + expect(Utils.encodeFormName('""')).toBe('%_%22%22'); + expect(Utils.encodeFormName('"22')).toBe('%_%2222'); + expect(Utils.encodeFormName('"a"')).toBe('%_%22a%22'); + expect(Utils.encodeFormName('a%22')).toBe('%_a%2522'); + expect(Utils.encodeFormName('"a%22')).toBe('%_%22a%2522'); + expect(Utils.encodeFormName('"a%222')).toBe('%_%22a%25222'); + }); +}); + describe('ensureRegionName', () => { it('should return default', () => { expect(Utils.ensureRegionName()).toEqual('query'); diff --git a/src/labkey/Utils.ts b/src/labkey/Utils.ts index 9bf05420..d02a2a80 100644 --- a/src/labkey/Utils.ts +++ b/src/labkey/Utils.ts @@ -335,6 +335,19 @@ export function encode(data: any): string { return JSON.stringify(data); } +/** + * Encodes a form value name for submission to a LabKey Server. + * + * @param name The form value name to encode. + * @return The encoded form value name. + */ +export function encodeFormName(name: string): string { + // Issue 52925, Issue 52119, Issue 54218 + // Should be consistent with PageFlowUtil.encodeFormName() on the server + if (!name || !/[\\"%]/.test(name)) return name; + return '%_' + encodeURIComponent(name); +} + /** * Encodes the html passed in and converts it to a String so that it will not be interpreted as HTML * by the browser. For example, if your input string was "<p>Hello</p>" the output would be diff --git a/src/labkey/query/Rows.spec.ts b/src/labkey/query/Rows.spec.ts index e92ebc5f..17195ae2 100644 --- a/src/labkey/query/Rows.spec.ts +++ b/src/labkey/query/Rows.spec.ts @@ -351,6 +351,8 @@ describe('bindSaveRowsData', () => { const fileA = new File([], ''); const fileB = new File([], ''); const fileC = new File([], ''); + const fileD = new File([], ''); + const fileE = new File([], ''); const form = bindSaveRowsData({ commands: [ { @@ -358,14 +360,26 @@ describe('bindSaveRowsData', () => { rows: [ { myFile: fileA, rowId: 1 }, { myFile: fileB, rowId: 2 }, + { 'file"Name': fileC, rowId: 3 }, + { 'file\\Name': fileD, rowId: 4 }, ], }, - { ...baseCommand, rows: [{ myFile: fileC, rowId: 3 }] }, + { ...baseCommand, rows: [{ myFile: fileE, rowId: 5 }] }, ], }); + expect(Array.from(form.keys()).sort()).toEqual([ + '%_file%22Name::0::2', + '%_file%5CName::0::3', + 'json', + 'myFile::0::0', + 'myFile::0::1', + 'myFile::1::0', + ]); expect(form.get('myFile::0::0')).toEqual(fileA); expect(form.get('myFile::0::1')).toEqual(fileB); - expect(form.get('myFile::1::0')).toEqual(fileC); + expect(form.get('%_file%22Name::0::2')).toEqual(fileC); + expect(form.get('%_file%5CName::0::3')).toEqual(fileD); + expect(form.get('myFile::1::0')).toEqual(fileE); expect(form.get('json')).toEqual( JSON.stringify({ commands: [ @@ -373,13 +387,13 @@ describe('bindSaveRowsData', () => { schemaName: 'schema', queryName: 'query', command: 'update', - rows: [{ rowId: 1 }, { rowId: 2 }], + rows: [{ rowId: 1 }, { rowId: 2 }, { rowId: 3 }, { rowId: 4 }], }, { schemaName: 'schema', queryName: 'query', command: 'update', - rows: [{ rowId: 3 }], + rows: [{ rowId: 5 }], }, ], }) diff --git a/src/labkey/query/Rows.ts b/src/labkey/query/Rows.ts index e5465685..a915ac19 100644 --- a/src/labkey/query/Rows.ts +++ b/src/labkey/query/Rows.ts @@ -15,7 +15,7 @@ */ import { request, RequestOptions } from '../Ajax'; import { buildURL } from '../ActionURL'; -import { getCallbackWrapper, getOnFailure, getOnSuccess, RequestCallbackOptions } from '../Utils'; +import { encodeFormName, getCallbackWrapper, getOnFailure, getOnSuccess, RequestCallbackOptions } from '../Utils'; import { AuditBehaviorTypes } from '../constants'; export interface QueryRequestOptions extends RequestCallbackOptions { @@ -273,18 +273,17 @@ export interface SaveRowsResponse { } export interface SaveRowsOptions extends RequestCallbackOptions { - - /** - * Optional audit details to record in the transaction audit log for this command. - */ - auditDetails?: Record; /** * Version of the API. If this is 13.2 or higher, a request that fails * validation will be returned as a successful response. Use the 'errorCount' and 'committed' properties in the * response to tell if it committed or not. If this is 13.1 or lower (or unspecified), the failure callback * will be invoked instead in the event of a validation failure. */ - apiVersion?: string | number; + apiVersion?: number | string; + /** + * Optional audit details to record in the transaction audit log for this command. + */ + auditDetails?: Record; /** An array of the update/insert/delete operations to be performed. */ commands: Command[]; /** @@ -303,15 +302,14 @@ export interface SaveRowsOptions extends RequestCallbackOptions { if (updatedRow[key] instanceof File) { - form.append(`${key}::${commandIndex}::${rowIndex}`, updatedRow[key]); + form.append(`${encodeFormName(key)}::${commandIndex}::${rowIndex}`, updatedRow[key]); delete updatedRow[key]; } }); @@ -420,7 +418,7 @@ export function bindFormData(jsonData: { rows?: any[] }, options: SendRequestOpt form = new FormData(); // Process and extract File data with row offsets - const rows: Array> = []; + const rows: Record[] = []; jsonData.rows.forEach((row, i) => { if (row) { @@ -429,7 +427,7 @@ export function bindFormData(jsonData: { rows?: any[] }, options: SendRequestOpt Object.keys(row).forEach(k => { // Extract File values from the row if (row[k] instanceof File) { - form.append(`${k}::${i}`, row[k]); + form.append(`${encodeFormName(k)}::${i}`, row[k]); } else { _row[k] = row[k]; } From 88447ddf5f981c09eab38ab0cde16f36bb1a7b9a Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 26 Mar 2026 10:53:03 -0700 Subject: [PATCH 2/2] 1.49.2-fb-encodeFormName.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 93231ac0..e65f2943 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/api", - "version": "1.49.1", + "version": "1.49.2-fb-encodeFormName.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/api", - "version": "1.49.1", + "version": "1.49.2-fb-encodeFormName.0", "license": "Apache-2.0", "devDependencies": { "@babel/core": "7.29.0", diff --git a/package.json b/package.json index 44d7005d..82392604 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/api", - "version": "1.49.1", + "version": "1.49.2-fb-encodeFormName.0", "description": "JavaScript client API for LabKey Server", "scripts": { "build": "npm run build:dist && npm run build:docs",