Skip to content

Commit e3e03c5

Browse files
committed
fix: the server validates minLength, maxLength, enum, array items, required on edit and custom validators instead of trusting the form; required columns hidden by showIf are found by the server, requiredColumnsToSkip from the client is ignored
1 parent 8f63d3f commit e3e03c5

10 files changed

Lines changed: 411 additions & 104 deletions

File tree

‎adminforth/index.ts‎

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import OperationalResource from './modules/operationalResource.js';
4141
import SocketBroker from './modules/socketBroker.js';
4242
import { afLogger } from './modules/logger.js';
4343
import { normalizeRecordValues } from './modules/columnValueNormalizer.js';
44+
import { isEmptyColumnValue, runRecordValidators, validateColumnValue } from './modules/columnValueValidator.js';
4445
import { createRequire } from 'module';
4546
import path from 'path';
4647
import { pathToFileURL } from 'url';
@@ -452,33 +453,11 @@ class AdminForth implements IAdminForth {
452453
}
453454
}
454455

455-
// check if record with minValue or maxValue is within limits
456-
for (const column of resource.columns.filter((col) => col.name in record
457-
&& ['integer', 'decimal', 'float'].includes(col.isArray?.enabled ? col.isArray.itemType : col.type)
458-
&& (col.minValue !== undefined || col.maxValue !== undefined))) {
459-
if (column.isArray?.enabled) {
460-
const error = record[column.name].reduce((err, item) => {
461-
if (err) return err;
462-
463-
if (column.minValue !== undefined && item < column.minValue) {
464-
return `Value in "${column.name}" must be greater than ${column.minValue}`;
465-
}
466-
if (column.maxValue !== undefined && item > column.maxValue) {
467-
return `Value in "${column.name}" must be less than ${column.maxValue}`;
468-
}
469-
470-
return null;
471-
}, null);
472-
if (error) {
473-
return error;
474-
}
475-
} else {
476-
if (column.minValue !== undefined && record[column.name] && record[column.name] < column.minValue) {
477-
return `Value in "${column.name}" must be greater than ${column.minValue}`;
478-
}
479-
if (column.maxValue !== undefined && record[column.name] && record[column.name] > column.maxValue) {
480-
return `Value in "${column.name}" must be less than ${column.maxValue}`;
481-
}
456+
// empty values are checked by required rules
457+
for (const column of resource.columns.filter((col) => col.name in record && !isEmptyColumnValue(col, record[col.name]))) {
458+
const error = validateColumnValue(column, record[column.name]);
459+
if (error) {
460+
return error;
482461
}
483462
}
484463

@@ -802,7 +781,8 @@ class AdminForth implements IAdminForth {
802781

803782
normalizeRecordValues(resource, record);
804783

805-
const err = this.validateRecordValues(resource, record, 'create');
784+
const err = this.validateRecordValues(resource, record, 'create')
785+
?? await runRecordValidators(resource, record, record, this);
806786
if (err) {
807787
return { error: err };
808788
}
@@ -894,7 +874,8 @@ class AdminForth implements IAdminForth {
894874
}
895875
const { resource, recordId, oldRecord, adminUser, extra, updates } = params;
896876
normalizeRecordValues(resource, updates);
897-
const err = this.validateRecordValues(resource, updates, 'edit');
877+
const err = this.validateRecordValues(resource, updates, 'edit')
878+
?? await runRecordValidators(resource, updates, { ...oldRecord, ...updates }, this);
898879
if (err) {
899880
return { error: err };
900881
}

‎adminforth/modules/columnValueNormalizer.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,18 @@ export function normalizeColumnValue(column: AdminForthResourceColumn, value: an
44
return column.normalize ? column.normalize(value) : value;
55
}
66

7+
function toLowerCase(value: any): any {
8+
if (Array.isArray(value)) {
9+
return value.map(toLowerCase);
10+
}
11+
return typeof value === 'string' ? value.toLowerCase() : value;
12+
}
13+
714
export function normalizeRecordValues(resource: AdminForthResource, record: Record<string, any>): void {
815
for (const column of resource.columns) {
916
if (column.name in record) {
10-
record[column.name] = normalizeColumnValue(column, record[column.name]);
17+
const value = column.enforceLowerCase ? toLowerCase(record[column.name]) : record[column.name];
18+
record[column.name] = normalizeColumnValue(column, value);
1119
}
1220
}
1321
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { AdminForthDataTypes } from '../types/Common.js';
2+
import type { AdminForthResource, AdminForthResourceColumn, IAdminForth } from '../types/Back.js';
3+
4+
const LENGTH_LIMITED_TYPES = [AdminForthDataTypes.STRING, AdminForthDataTypes.TEXT];
5+
const VALUE_LIMITED_TYPES = [AdminForthDataTypes.INTEGER, AdminForthDataTypes.DECIMAL, AdminForthDataTypes.FLOAT];
6+
7+
function isEmptyValue(value: any): boolean {
8+
return value === undefined || value === null || value === '';
9+
}
10+
11+
// connectors discover an absent limit as null
12+
function isLimitSet(limit: number | null | undefined): boolean {
13+
return limit !== undefined && limit !== null;
14+
}
15+
16+
/**
17+
* Same emptiness as the create/edit form uses for required columns
18+
*/
19+
export function isEmptyColumnValue(column: AdminForthResourceColumn, value: any): boolean {
20+
return isEmptyValue(value) || (!!column.isArray?.enabled && value.length === 0);
21+
}
22+
23+
function validateItemValue(column: AdminForthResourceColumn, type: AdminForthDataTypes, value: any): string | null {
24+
if (LENGTH_LIMITED_TYPES.includes(type)) {
25+
if (isLimitSet(column.maxLength) && value.length > column.maxLength) {
26+
return `Value in "${column.name}" must not be longer than ${column.maxLength} characters`;
27+
}
28+
if (isLimitSet(column.minLength) && value.length < column.minLength) {
29+
return `Value in "${column.name}" must not be shorter than ${column.minLength} characters`;
30+
}
31+
}
32+
if (VALUE_LIMITED_TYPES.includes(type)) {
33+
if (isLimitSet(column.minValue) && value < column.minValue) {
34+
return `Value in "${column.name}" must be greater than ${column.minValue}`;
35+
}
36+
if (isLimitSet(column.maxValue) && value > column.maxValue) {
37+
return `Value in "${column.name}" must be less than ${column.maxValue}`;
38+
}
39+
}
40+
if (column.enum && !column.enum.some((item) => item.value === value)) {
41+
return `Value in "${column.name}" must be one of: ${column.enum.map((item) => item.value).join(', ')}`;
42+
}
43+
return null;
44+
}
45+
46+
/**
47+
* Checks a non-empty column value against length and value limits, enum and array rules
48+
*/
49+
export function validateColumnValue(column: AdminForthResourceColumn, value: any): string | null {
50+
if (!column.isArray?.enabled) {
51+
return validateItemValue(column, column.type, value);
52+
}
53+
if (value.some(isEmptyValue)) {
54+
return `Array in "${column.name}" cannot contain empty items`;
55+
}
56+
if (!column.isArray.allowDuplicateItems && new Set(value).size !== value.length) {
57+
return `Array in "${column.name}" cannot contain duplicate items`;
58+
}
59+
for (const item of value) {
60+
const error = validateItemValue(column, column.isArray.itemType, item);
61+
if (error) {
62+
return error;
63+
}
64+
}
65+
return null;
66+
}
67+
68+
/**
69+
* Runs custom `validator` callbacks of the column and returns the first failed result
70+
*/
71+
export async function runColumnValidators(
72+
column: AdminForthResourceColumn,
73+
value: any,
74+
record: any,
75+
adminforth: IAdminForth,
76+
): Promise<{ isValid: false, message?: string } | null> {
77+
for (const val of column.validation ?? []) {
78+
if (val.validator) {
79+
const result = await val.validator(value, record, adminforth);
80+
if (typeof result === 'object' && result.isValid === false) {
81+
return { isValid: false, message: result.message };
82+
}
83+
}
84+
}
85+
return null;
86+
}
87+
88+
/**
89+
* Runs custom validators of the columns present in `values`, `record` is the whole record they are passed
90+
*/
91+
export async function runRecordValidators(
92+
resource: AdminForthResource,
93+
values: any,
94+
record: any,
95+
adminforth: IAdminForth,
96+
): Promise<string | null> {
97+
for (const column of resource.columns.filter((col) => col.name in values)) {
98+
const failed = await runColumnValidators(column, values[column.name], record, adminforth);
99+
if (failed) {
100+
return failed.message ?? `Value in "${column.name}" is invalid`;
101+
}
102+
}
103+
return null;
104+
}

‎adminforth/modules/restApi.ts‎

Lines changed: 29 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { encodeRecordId, isCompositePrimaryKey, primaryKeyColumnNames } from './
2626

2727
import { afLogger } from "./logger.js";
2828

29-
import { ADMINFORTH_VERSION, listify, md5hash, getLoginPromptHTML, hookResponseError, parseLooseJson, RateLimiter } from './utils.js';
29+
import { ADMINFORTH_VERSION, listify, md5hash, getLoginPromptHTML, hookResponseError, parseLooseJson, RateLimiter, checkShowIf } from './utils.js';
3030

3131
import AdminForthAuth from "../auth.js";
3232
import { ActionCheckSource, AdminForthActionFront, AdminForthConfigMenuItem, AdminForthDataTypes, AdminForthFilterOperators, AdminForthResourceColumnInputCommon, AdminForthResourceFrontend, AdminForthResourcePages,
@@ -37,6 +37,7 @@ import { ActionCheckSource, AdminForthActionFront, AdminForthConfigMenuItem, Adm
3737
ShowInResolved} from "../types/Common.js";
3838
import { filtersTools } from "../modules/filtersTools.js";
3939
import { normalizeColumnValue } from './columnValueNormalizer.js';
40+
import { isEmptyColumnValue, runColumnValidators } from './columnValueValidator.js';
4041

4142

4243
async function resolveBoolOrFn(
@@ -127,16 +128,6 @@ const actionIdentifierSchema: AnySchemaObject = {
127128
],
128129
};
129130

130-
const namedColumnSchema: AnySchemaObject = {
131-
title: 'AdminForthNamedColumn',
132-
type: 'object',
133-
required: ['name'],
134-
properties: {
135-
name: { type: 'string' },
136-
},
137-
additionalProperties: true,
138-
};
139-
140131
const validationResultSchema: AnySchemaObject = {
141132
title: 'AdminForthValidationResult',
142133
type: 'object',
@@ -489,14 +480,10 @@ const getMinMaxForColumnsResponseSchema: AnySchemaObject = createErrorOrSuccessS
489480

490481
const createRecordRequestSchema: AnySchemaObject = {
491482
type: 'object',
492-
required: ['resourceId', 'record', 'requiredColumnsToSkip'],
483+
required: ['resourceId', 'record'],
493484
properties: {
494485
resourceId: { type: 'string' },
495486
record: genericObjectSchema,
496-
requiredColumnsToSkip: {
497-
type: 'array',
498-
items: namedColumnSchema,
499-
},
500487
meta: genericObjectSchema,
501488
},
502489
additionalProperties: true,
@@ -2346,19 +2333,24 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
23462333
return { error };
23472334
}
23482335

2349-
const { record, requiredColumnsToSkip } = body;
2336+
const { record } = body;
2337+
2338+
const ctxCreate = {
2339+
adminUser,
2340+
resource,
2341+
meta: { requestBody: body },
2342+
source: ActionCheckSource.CreateRequest,
2343+
adminforth: this.adminforth,
2344+
};
23502345

2351-
// todo if showIn.create is function, code below will be buggy (will not detect required fact)
23522346
for (const column of resource.columns) {
23532347
if (
2354-
(column.required as {create?: boolean, edit?: boolean})?.create &&
2355-
record[column.name] === undefined &&
2356-
column.showIn.create
2348+
column.required.create &&
2349+
isEmptyColumnValue(column, record[column.name]) &&
2350+
await isShown(column, 'create', ctxCreate) &&
2351+
checkShowIf(column, record, resource.columns)
23572352
) {
2358-
const shouldWeSkipColumn = requiredColumnsToSkip.find(reqColumnToSkip => reqColumnToSkip.name === column.name);
2359-
if (!shouldWeSkipColumn) {
2360-
return { error: `Column '${column.name}' is required`, ok: false };
2361-
}
2353+
return { error: `Column '${column.name}' is required`, ok: false };
23622354
}
23632355
}
23642356

@@ -2385,26 +2377,6 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
23852377
}
23862378
}
23872379

2388-
const ctxCreate = {
2389-
adminUser,
2390-
resource,
2391-
meta: { requestBody: body },
2392-
source: ActionCheckSource.CreateRequest,
2393-
adminforth: this.adminforth,
2394-
};
2395-
2396-
for (const column of resource.columns) {
2397-
if ((column.required as { create?: boolean })?.create) {
2398-
const shown = await isShown(column, 'create', ctxCreate);
2399-
const shouldWeSkipColumn = requiredColumnsToSkip.find(reqColumnToSkip => reqColumnToSkip.name === column.name);
2400-
if (!shouldWeSkipColumn) {
2401-
if (shown && record[column.name] === undefined) {
2402-
return { error: `Column '${column.name}' is required`, ok: false };
2403-
}
2404-
}
2405-
}
2406-
}
2407-
24082380
for (const column of resource.columns) {
24092381
const fieldName = column.name;
24102382
if (fieldName in record) {
@@ -2600,6 +2572,15 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
26002572
ok: false,
26012573
};
26022574
}
2575+
2576+
if (
2577+
column.required.edit &&
2578+
shown &&
2579+
isEmptyColumnValue(column, record[fieldName]) &&
2580+
checkShowIf(column, { ...oldRecord, ...record }, resource.columns)
2581+
) {
2582+
return { error: `Column '${fieldName}' is required`, ok: false };
2583+
}
26032584
}
26042585
}
26052586
// for polymorphic foreign resources, we need to find out the value for polymorphicOn column
@@ -2888,17 +2869,9 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
28882869
const columnConfig = resource.columns.find((c) => c.name === col.name);
28892870
if (columnConfig && columnConfig.validation) {
28902871
customColumnValidatorsFunctions.push(async ()=>{
2891-
for (const val of columnConfig.validation) {
2892-
if (val.validator) {
2893-
const result = await val.validator(col.value, record, this.adminforth);
2894-
if (typeof result === 'object' && result.isValid === false) {
2895-
validationResults[col.name] = {
2896-
isValid: result.isValid,
2897-
message: result.message,
2898-
}
2899-
break;
2900-
}
2901-
}
2872+
const failedResult = await runColumnValidators(columnConfig, col.value, record, this.adminforth);
2873+
if (failedResult) {
2874+
validationResults[col.name] = failedResult;
29022875
}
29032876
})
29042877
}

0 commit comments

Comments
 (0)