Skip to content

Commit 5d69501

Browse files
authored
Merge pull request #175 from devforth/next
Next
2 parents 4f0adce + e38fa29 commit 5d69501

File tree

10 files changed

+204
-17
lines changed

10 files changed

+204
-17
lines changed

adminforth/commands/createApp/templates/adminuser.ts.hbs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,15 @@ export default {
8888
hooks: {
8989
create: {
9090
beforeSave: async ({ record, adminUser, resource }: { record: any, adminUser: AdminUser, resource: AdminForthResource }) => {
91-
record.passwordHash = await AdminForth.Utils.generatePasswordHash(record.password);
91+
record.password_hash = await AdminForth.Utils.generatePasswordHash(record.password);
9292
return { ok: true };
9393
}
9494
},
9595
edit: {
9696
beforeSave: async ({ updates, adminUser, resource }: { updates: any, adminUser: AdminUser, resource: AdminForthResource }) => {
9797
console.log('Updating user', updates);
9898
if (updates.password) {
99-
updates.passwordHash = await AdminForth.Utils.generatePasswordHash(updates.password);
99+
updates.password_hash = await AdminForth.Utils.generatePasswordHash(updates.password);
100100
}
101101
return { ok: true }
102102
},

adminforth/dataConnectors/baseConnector.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,17 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
5656
}, { ok: true, error: '' });
5757
}
5858

59-
if (!filters.operator) {
60-
return { ok: false, error: `Field "operator" not specified in filter object: ${JSON.stringify(filters)}` };
61-
}
62-
6359
if ((filters as IAdminForthSingleFilter).field) {
6460
// if "field" is present, filter must be Single
61+
if (!filters.operator) {
62+
return { ok: false, error: `Field "operator" not specified in filter object: ${JSON.stringify(filters)}` };
63+
}
64+
if ((filters as IAdminForthSingleFilter).value === undefined) {
65+
return { ok: false, error: `Field "value" not specified in filter object: ${JSON.stringify(filters)}` };
66+
}
67+
if ((filters as IAdminForthSingleFilter).insecureRawSQL) {
68+
return { ok: false, error: `Field "insecureRawSQL" should not be specified in filter object alongside "field": ${JSON.stringify(filters)}` };
69+
}
6570
if (![AdminForthFilterOperators.EQ, AdminForthFilterOperators.NE, AdminForthFilterOperators.GT,
6671
AdminForthFilterOperators.LT, AdminForthFilterOperators.GTE, AdminForthFilterOperators.LTE,
6772
AdminForthFilterOperators.LIKE, AdminForthFilterOperators.ILIKE, AdminForthFilterOperators.IN,
@@ -73,6 +78,7 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
7378
const similar = suggestIfTypo(resource.dataSourceColumns.map((col) => col.name), (filters as IAdminForthSingleFilter).field);
7479
throw new Error(`Field '${(filters as IAdminForthSingleFilter).field}' not found in resource '${resource.resourceId}'. ${similar ? `Did you mean '${similar}'?` : ''}`);
7580
}
81+
// value normalization
7682
if (filters.operator == AdminForthFilterOperators.IN || filters.operator == AdminForthFilterOperators.NIN) {
7783
if (!Array.isArray(filters.value)) {
7884
return { ok: false, error: `Value for operator '${filters.operator}' should be an array, in filter object: ${JSON.stringify(filters) }` };
@@ -85,8 +91,19 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
8591
} else {
8692
(filters as IAdminForthSingleFilter).value = this.setFieldValue(fieldObj, (filters as IAdminForthSingleFilter).value);
8793
}
94+
} else if ((filters as IAdminForthSingleFilter).insecureRawSQL) {
95+
// if "insecureRawSQL" filter is insecure sql string
96+
if ((filters as IAdminForthSingleFilter).operator) {
97+
return { ok: false, error: `Field "operator" should not be specified in filter object alongside "insecureRawSQL": ${JSON.stringify(filters)}` };
98+
}
99+
if ((filters as IAdminForthSingleFilter).value !== undefined) {
100+
return { ok: false, error: `Field "value" should not be specified in filter object alongside "insecureRawSQL": ${JSON.stringify(filters)}` };
101+
}
88102
} else if ((filters as IAdminForthAndOrFilter).subFilters) {
89103
// if "subFilters" is present, filter must be AndOr
104+
if (!filters.operator) {
105+
return { ok: false, error: `Field "operator" not specified in filter object: ${JSON.stringify(filters)}` };
106+
}
90107
if (![AdminForthFilterOperators.AND, AdminForthFilterOperators.OR].includes(filters.operator)) {
91108
return { ok: false, error: `Field "operator" has wrong value in filter object: ${JSON.stringify(filters)}` };
92109
}

adminforth/dataConnectors/clickhouse.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,14 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
185185
return `${field} ${operator} ${placeholder}`;
186186
}
187187

188+
// filter is a single insecure raw sql
189+
if ((filter as IAdminForthSingleFilter).insecureRawSQL) {
190+
return (filter as IAdminForthSingleFilter).insecureRawSQL;
191+
}
192+
188193
// filter is a AndOr filter
189194
return (filter as IAdminForthAndOrFilter).subFilters.map((f) => {
190-
if ((f as IAdminForthSingleFilter).field) {
195+
if ((f as IAdminForthSingleFilter).field || (f as IAdminForthSingleFilter).insecureRawSQL) {
191196
// subFilter is a Single filter
192197
return this.getFilterString(resource, f);
193198
}
@@ -209,6 +214,11 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
209214
}
210215
}
211216

217+
// filter is a Single insecure raw sql
218+
if ((filter as IAdminForthSingleFilter).insecureRawSQL) {
219+
return [];
220+
}
221+
212222
// filter is a AndOrFilter
213223
return (filter as IAdminForthAndOrFilter).subFilters.reduce((params: any[], f: IAdminForthSingleFilter | IAdminForthAndOrFilter) => {
214224
return params.concat(this.getFilterParams(f));
@@ -310,6 +320,13 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
310320
filters: IAdminForthAndOrFilter;
311321
}): Promise<number> {
312322
const tableName = resource.table;
323+
// validate and normalize in case this method is called from dataAPI
324+
if (filters) {
325+
const filterValidation = this.validateAndNormalizeFilters(filters, resource);
326+
if (!filterValidation.ok) {
327+
throw new Error(filterValidation.error);
328+
}
329+
}
313330
const { where, params } = this.whereClause(resource, filters);
314331

315332
const countQ = await this.client.query({

adminforth/dataConnectors/mongo.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,10 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
125125
}
126126

127127
// filter is a AndOr filter
128-
return this.OperatorsMap[filter.operator]((filter as IAdminForthAndOrFilter).subFilters.map((f) => this.getFilterQuery(resource, f)));
128+
return this.OperatorsMap[filter.operator]((filter as IAdminForthAndOrFilter).subFilters
129+
// mongodb should ignore raw sql
130+
.filter((f) => (f as IAdminForthSingleFilter).insecureRawSQL === undefined)
131+
.map((f) => this.getFilterQuery(resource, f)));
129132
}
130133

131134
async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }:
@@ -162,7 +165,13 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
162165
resource: AdminForthResource,
163166
filters: IAdminForthAndOrFilter,
164167
}): Promise<number> {
165-
168+
if (filters) {
169+
// validate and normalize in case this method is called from dataAPI
170+
const filterValidation = this.validateAndNormalizeFilters(filters, resource);
171+
if (!filterValidation.ok) {
172+
throw new Error(filterValidation.error);
173+
}
174+
}
166175
const collection = this.client.db().collection(resource.table);
167176
const query = filters.subFilters.length ? this.getFilterQuery(resource, filters) : {};
168177
return await collection.countDocuments(query);

adminforth/dataConnectors/mysql.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,14 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS
186186
return `${field} ${operator} ${placeholder}`;
187187
}
188188

189+
// filter is a single insecure raw sql
190+
if ((filter as IAdminForthSingleFilter).insecureRawSQL) {
191+
return (filter as IAdminForthSingleFilter).insecureRawSQL;
192+
}
193+
189194
// filter is a AndOr filter
190195
return (filter as IAdminForthAndOrFilter).subFilters.map((f) => {
191-
if ((f as IAdminForthSingleFilter).field) {
196+
if ((f as IAdminForthSingleFilter).field || (f as IAdminForthSingleFilter).insecureRawSQL) {
192197
// subFilter is a Single filter
193198
return this.getFilterString(f);
194199
}
@@ -209,6 +214,11 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS
209214
}
210215
}
211216

217+
// filter is a Single insecure raw sql
218+
if ((filter as IAdminForthSingleFilter).insecureRawSQL) {
219+
return [];
220+
}
221+
212222
// filter is a AndOrFilter
213223
return (filter as IAdminForthAndOrFilter).subFilters.reduce((params: any[], f: IAdminForthSingleFilter | IAdminForthAndOrFilter) => {
214224
return params.concat(this.getFilterParams(f));
@@ -252,6 +262,13 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS
252262

253263
async getCount({ resource, filters }: { resource: AdminForthResource; filters: IAdminForthAndOrFilter; }): Promise<number> {
254264
const tableName = resource.table;
265+
// validate and normalize in case this method is called from dataAPI
266+
if (filters) {
267+
const filterValidation = this.validateAndNormalizeFilters(filters, resource);
268+
if (!filterValidation.ok) {
269+
throw new Error(filterValidation.error);
270+
}
271+
}
255272
const { sql: where, values: filterValues } = this.whereClauseAndValues(filters);
256273
const q = `SELECT COUNT(*) FROM ${tableName} ${where}`;
257274
if (process.env.HEAVY_DEBUG_QUERY) {

adminforth/dataConnectors/postgres.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,14 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa
219219
return `${field} ${operator} ${placeholder}`;
220220
}
221221

222+
// filter is a single insecure raw sql
223+
if ((filter as IAdminForthSingleFilter).insecureRawSQL) {
224+
return (filter as IAdminForthSingleFilter).insecureRawSQL;
225+
}
226+
222227
// filter is a AndOr filter
223228
return (filter as IAdminForthAndOrFilter).subFilters.map((f) => {
224-
if ((f as IAdminForthSingleFilter).field) {
229+
if ((f as IAdminForthSingleFilter).field || (f as IAdminForthSingleFilter).insecureRawSQL) {
225230
// subFilter is a Single filter
226231
return this.getFilterString(resource, f);
227232
}
@@ -243,6 +248,11 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa
243248
}
244249
}
245250

251+
// filter is a single insecure raw sql
252+
if ((filter as IAdminForthSingleFilter).insecureRawSQL) {
253+
return [];
254+
}
255+
246256
// filter is a AndOrFilter
247257
return (filter as IAdminForthAndOrFilter).subFilters.reduce((params: any[], f: IAdminForthSingleFilter | IAdminForthAndOrFilter) => {
248258
return params.concat(this.getFilterParams(f));
@@ -291,6 +301,13 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa
291301

292302
async getCount({ resource, filters }: { resource: AdminForthResource; filters: IAdminForthAndOrFilter; }): Promise<number> {
293303
const tableName = resource.table;
304+
// validate and normalize in case this method is called from dataAPI
305+
if (filters) {
306+
const filterValidation = this.validateAndNormalizeFilters(filters, resource);
307+
if (!filterValidation.ok) {
308+
throw new Error(filterValidation.error);
309+
}
310+
}
294311
const { sql: where, values: filterValues } = this.whereClauseAndValues(resource, filters);
295312
const q = `SELECT COUNT(*) FROM "${tableName}" ${where}`;
296313
if (process.env.HEAVY_DEBUG_QUERY) {

adminforth/dataConnectors/sqlite.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,14 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData
172172
return `${field} ${operator} ${placeholder}`;
173173
}
174174

175+
// filter is a single insecure raw sql
176+
if ((filter as IAdminForthSingleFilter).insecureRawSQL) {
177+
return (filter as IAdminForthSingleFilter).insecureRawSQL;
178+
}
179+
175180
// filter is a AndOr filter
176181
return (filter as IAdminForthAndOrFilter).subFilters.map((f) => {
177-
if ((f as IAdminForthSingleFilter).field) {
182+
if ((f as IAdminForthSingleFilter).field || (f as IAdminForthSingleFilter).insecureRawSQL) {
178183
// subFilter is a Single filter
179184
return this.getFilterString(f);
180185
}
@@ -195,6 +200,11 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData
195200
}
196201
}
197202

203+
// filter is a Single insecure raw sql
204+
if ((filter as IAdminForthSingleFilter).insecureRawSQL) {
205+
return [];
206+
}
207+
198208
// filter is a AndOrFilter
199209
return (filter as IAdminForthAndOrFilter).subFilters.reduce((params: any[], f: IAdminForthSingleFilter | IAdminForthAndOrFilter) => {
200210
return params.concat(this.getFilterParams(f));
@@ -234,6 +244,7 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData
234244

235245
async getCount({ resource, filters }) {
236246
if (filters) {
247+
// validate and normalize in case this method is called from dataAPI
237248
const filterValidation = this.validateAndNormalizeFilters(filters, resource);
238249
if (!filterValidation.ok) {
239250
throw new Error(filterValidation.error);

adminforth/documentation/docs/tutorial/03-Customization/03-virtualColumns.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ columns: [
6767
resource: AdminForthResourceCommon;
6868
adminUser: AdminUser
6969
}>();
70+
71+
###
7072
7173
function getFlagEmojiFromIso(iso) {
7274
return iso?.toUpperCase()?.replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397));
@@ -78,6 +80,102 @@ Here is how it looks:
7880
7981
![alt text](<Virtual columns.png>)
8082
83+
## Virtual columns for filtering.
84+
85+
Virtual column can also be used as a shorthand for a complex filtering.
86+
Lets say we want to divide apartments into two types: "base" ones and "luxury" and then allow admins to filter apartments by this category. Condition for being a "luxury" apartment is either having more then 80 sq.m area or costing more then 100k.
87+
One way to do it is to actually add a real column to a table and then fill it every time new apartment is added. A more simple way is to add a virtual column and then use `list.beforeDatasourceRequest` hook to replace filtering on this column with desired one.
88+
For this purpose following changes will be required for apartments config:
89+
90+
```ts title='./resources/apartments.ts'
91+
...
92+
resourceId: 'aparts',
93+
...
94+
hooks: {
95+
...
96+
list: {
97+
beforeDatasourceRequest: async ({ query }: { query: any }) => {
98+
query.filters = query.filters.map((filter: any) => {
99+
// replace apartment_type filter with complex one
100+
if (filter.field === 'apartment_type') {
101+
if (filter.value === 'luxury') {
102+
return Filters.OR([Filters.GTE('square_meter', 80), Filters.GTE('price', 100000)]);
103+
}
104+
105+
// filter for "base" apartment as default
106+
return Filters.AND([Filters.LT('square_meter', 80), Filters.LT('price', 100000)]);
107+
}
108+
109+
return filter;
110+
});
111+
return { ok: true, error: "" };
112+
},
113+
...
114+
},
115+
...
116+
},
117+
...
118+
columns: [
119+
...
120+
{
121+
name: "apartment_type",
122+
virtual: true,
123+
showIn: { all: false, filter: true }, // hide it from display everywhere, except filter page
124+
enum: [
125+
{
126+
value: 'base',
127+
label: 'Base',
128+
},
129+
{
130+
value: 'luxury',
131+
label: 'Luxury'
132+
},
133+
],
134+
filterOptions: {
135+
multiselect: false, // allow to only select one category when filtering
136+
},
137+
},
138+
...
139+
]
140+
```
141+
This way, when admin selects, for example, "Luxury" option for "Apartment Type" filter, it will be replace with a more complex "or" filter.
142+
143+
### Custom SQL queries with `insecureRawSQL`
144+
145+
Rarely the sec of Filters supported by AdminForth is not enough for your needs.
146+
In this case you can use `insecureRawSQL` to write your own part of where clause.
147+
148+
However the vital concern that the SQL passed to DB as is, so if you substitute any user inputs it will not be escaped and can lead to SQL injection. To miticate the issue we recommend using `sqlstring` package which will escape the inputs for you.
149+
150+
```bash
151+
npm i sqlstring
152+
```
153+
154+
Then you can use it like this:
155+
156+
```ts title='./resources/apartments.ts'
157+
import sqlstring from 'sqlstring';
158+
...
159+
160+
beforeDatasourceRequest: async ({ query }: { query: any }) => {
161+
query.filters = query.filters.map((filter: any) => {
162+
// replace apartment_type filter with complex one
163+
if (filter.field === 'some_json_b_field') {
164+
return {
165+
// check if some_json_b_field->'$.some_field' is equal to filter.value
166+
insecureRawSQL: `some_json_b_field->'$.some_field' = ${sqlstring.escape(filter.value)}`,
167+
}
168+
}
169+
170+
return filter;
171+
});
172+
return { ok: true, error: "" };
173+
}
174+
```
175+
176+
This example will allow to search for some nested field in JSONB column, however you can use any SQL query here.
177+
178+
81179
82180
## Virtual columns for editing.
83181

adminforth/modules/restApi.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,7 +1019,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
10191019
const fieldName = column.name;
10201020
if (fieldName in record) {
10211021
if (!column.showIn?.create || column.backendOnly) {
1022-
return { error: `Field "${fieldName}" cannot be modified as it is restricted from creation`, ok: false };
1022+
return { error: `Field "${fieldName}" cannot be modified as it is restricted from creation (showIn.create is false, please set it to true)`, ok: false };
10231023
}
10241024
}
10251025
}
@@ -1115,7 +1115,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
11151115
const fieldName = column.name;
11161116
if (fieldName in record) {
11171117
if (!column.showIn?.edit || column.editReadonly || column.backendOnly) {
1118-
return { error: `Field "${fieldName}" cannot be modified as it is restricted from editing` };
1118+
return { error: `Field "${fieldName}" cannot be modified as it is restricted from editing (showIn.edit is false, please set it to true)`, ok: false };
11191119
}
11201120
}
11211121
}

0 commit comments

Comments
 (0)