Skip to content

Commit d0afe90

Browse files
authored
Merge pull request #16 from devforth/improve-security
fix: add limit for parallel import process tasks, execute hooks on cr…
2 parents f390a3d + 98c6711 commit d0afe90

4 files changed

Lines changed: 155 additions & 26 deletions

File tree

index.ts

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,38 @@
1-
import { AdminForthPlugin, suggestIfTypo, AdminForthFilterOperators, Filters, AdminForthDataTypes } from "adminforth";
1+
import { AdminForthPlugin, suggestIfTypo, AdminForthFilterOperators, Filters, AdminForthDataTypes, rejectApiRawFilters } from "adminforth";
22
import type { IAdminForth, IHttpServer, AdminForthResourceColumn, AdminForthComponentDeclaration, AdminForthResource } from "adminforth";
33
import type { PluginOptions } from './types.js';
4+
import pLimit from 'p-limit';
45

56
export default class ImportExport extends AdminForthPlugin {
67
options: PluginOptions;
78
emailField: AdminForthResourceColumn;
89
authResourceId: string;
910
adminforth: IAdminForth;
11+
1012

1113
constructor(options: PluginOptions) {
1214
super(options, import.meta.url);
1315
this.options = options;
1416
}
1517

18+
private isRowValid(row: Record<string, unknown>): string[] {
19+
let errors = [];
20+
for (const col of Object.keys(row)) {
21+
const resourceCol = this.resourceConfig.columns.find(c => c.name === col);
22+
if (!resourceCol) {
23+
errors.push(`Column '${col}' not found in resource configuration.`);
24+
continue;
25+
}
26+
if (resourceCol.backendOnly) {
27+
errors.push(`Column '${col}' is backend only and cannot be imported.`);
28+
}
29+
if (resourceCol.enum && !resourceCol.enum.some(e => e.value === row[col])) {
30+
errors.push(`Column '${col}' has an enum of [${resourceCol.enum.map(e => e.label).join(', ')}] but got value '${row[col]}'.`);
31+
}
32+
}
33+
return errors;
34+
}
35+
1636
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
1737
super.modifyResourceConfig(adminforth, resourceConfig);
1838
if (!resourceConfig.options.pageInjections) {
@@ -55,7 +75,10 @@ export default class ImportExport extends AdminForthPlugin {
5575
path: `/plugin/${this.pluginInstanceId}/export-csv`,
5676
handler: async ({ body }) => {
5777
const { filters, sort } = body;
58-
78+
const rawFilterError = rejectApiRawFilters(body.filters);
79+
if (rawFilterError) {
80+
return rawFilterError;
81+
}
5982
const data = await this.adminforth.connectors[this.resourceConfig.dataSource].getData({
6083
resource: this.resourceConfig,
6184
limit: 1e6,
@@ -66,7 +89,7 @@ export default class ImportExport extends AdminForthPlugin {
6689
});
6790

6891
// prepare data for PapaParse unparse
69-
const columns = this.resourceConfig.columns.filter((col) => !col.virtual);
92+
const columns = this.resourceConfig.columns.filter((col) => !col.virtual && !col.backendOnly);
7093

7194
const columnsToForceQuote = columns.map(col => {
7295
return col.type !== AdminForthDataTypes.FLOAT
@@ -92,10 +115,12 @@ export default class ImportExport extends AdminForthPlugin {
92115
server.endpoint({
93116
method: 'POST',
94117
path: `/plugin/${this.pluginInstanceId}/import-csv`,
95-
handler: async ({ body }) => {
118+
handler: async ({ body, adminUser, query, headers, cookies, requestUrl, response }) => {
96119
const { data } = body;
97120
const columns = this.getColumnNames(data);
98121
const { errors, resourceColumns } = this.validateColumns(columns);
122+
const resource = this.adminforth.config.resources.find(r => r.resourceId === this.resourceConfig.resourceId);
123+
99124
if (errors.length > 0) {
100125
return { ok: false, errors };
101126
}
@@ -106,26 +131,49 @@ export default class ImportExport extends AdminForthPlugin {
106131

107132
let importedCount = 0;
108133
let updatedCount = 0;
134+
const limit = pLimit(100);
109135

110-
await Promise.all(rows.map(async (row) => {
136+
await Promise.all(rows.map((row) => limit(async () => {
111137
try {
112-
if (primaryKeyColumn && row[primaryKeyColumn.name]) {
138+
const rowErrors = await this.isRowValid(row);
139+
if (rowErrors.length > 0) {
140+
errors.push(...rowErrors);
141+
return;
142+
}
143+
const recordId = primaryKeyColumn ? row[primaryKeyColumn.name] as string : undefined;
144+
if (primaryKeyColumn && recordId) {
113145
const existingRecord = await this.adminforth.resource(this.resourceConfig.resourceId)
114-
.list([Filters.EQ(primaryKeyColumn.name, row[primaryKeyColumn.name])]);
146+
.list([Filters.EQ(primaryKeyColumn.name, recordId)]);
115147

116148
if (existingRecord.length > 0) {
117-
await this.adminforth.resource(this.resourceConfig.resourceId)
118-
.update(row[primaryKeyColumn.name], row);
149+
const connector = this.adminforth.connectors[resource.dataSource];
150+
const oldRecord = await connector.getRecordByPrimaryKey(resource, recordId)
151+
if (!oldRecord) {
152+
const primaryKeyColumn = resource.columns.find((col) => col.primaryKey);
153+
return { error: `Record with ${primaryKeyColumn.name} ${recordId} not found` };
154+
}
155+
const { error } = await this.adminforth.updateResourceRecord({
156+
resource, updates: row, adminUser, oldRecord, recordId, response,
157+
extra: { body, query, headers, cookies, requestUrl, response }
158+
});
159+
if (error) {
160+
return { error };
161+
}
119162
updatedCount++;
120163
return;
121164
}
122165
}
123-
await this.adminforth.resource(this.resourceConfig.resourceId).create(row);
166+
await this.adminforth.createResourceRecord({
167+
resource: resource,
168+
record: row,
169+
adminUser: adminUser,
170+
extra: { body, query, headers, cookies, requestUrl, response }
171+
});
124172
importedCount++;
125173
} catch (e) {
126174
errors.push(e.message);
127175
}
128-
}));
176+
})));
129177

130178
return { ok: true, importedCount, updatedCount, errors };
131179
}
@@ -134,9 +182,10 @@ export default class ImportExport extends AdminForthPlugin {
134182
server.endpoint({
135183
method: 'POST',
136184
path: `/plugin/${this.pluginInstanceId}/import-csv-new-only`,
137-
handler: async ({ body }) => {
185+
handler: async ({ body, adminUser, query, headers, cookies, requestUrl, response }) => {
138186
const { data } = body;
139187
const columns = this.getColumnNames(data);
188+
const resource = this.adminforth.config.resources.find(r => r.resourceId === this.resourceConfig.resourceId);
140189
const { errors, resourceColumns } = this.validateColumns(columns);
141190
if (errors.length > 0) {
142191
return { ok: false, errors };
@@ -146,9 +195,15 @@ export default class ImportExport extends AdminForthPlugin {
146195
const rows = this.buildRowsFromData(data, columns, resourceColumns, { coerceTypes: true });
147196

148197
let importedCount = 0;
198+
const limit = pLimit(100);
149199

150-
await Promise.all(rows.map(async (row) => {
200+
await Promise.all(rows.map((row) => limit(async () => {
151201
try {
202+
const rowErrors = await this.isRowValid(row);
203+
if (rowErrors.length > 0) {
204+
errors.push(...rowErrors);
205+
return;
206+
}
152207
if (primaryKeyColumn && row[primaryKeyColumn.name]) {
153208
const existingRecord = await this.adminforth.resource(this.resourceConfig.resourceId)
154209
.list([Filters.EQ(primaryKeyColumn.name, row[primaryKeyColumn.name])]);
@@ -157,12 +212,17 @@ export default class ImportExport extends AdminForthPlugin {
157212
return;
158213
}
159214
}
160-
await this.adminforth.resource(this.resourceConfig.resourceId).create(row);
215+
await this.adminforth.createResourceRecord({
216+
resource: resource,
217+
record: row,
218+
adminUser: adminUser,
219+
extra: { body, query, headers, cookies, requestUrl, response }
220+
});
161221
importedCount++;
162222
} catch (e) {
163223
errors.push(e.message);
164224
}
165-
}));
225+
})));
166226

167227
return { ok: true, importedCount, errors };
168228
}

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
},
3030
"devDependencies": {
3131
"@types/node": "^22.10.7",
32-
"adminforth": "^3.1.0",
32+
"adminforth": "^3.4.0",
3333
"semantic-release": "^24.2.1",
3434
"semantic-release-slack-bot": "^4.0.2",
3535
"typescript": "^5.7.3"
@@ -58,5 +58,8 @@
5858
"prerelease": true
5959
}
6060
]
61+
},
62+
"dependencies": {
63+
"p-limit": "^7.3.0"
6164
}
6265
}

pnpm-lock.yaml

Lines changed: 72 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
allowBuilds:
2+
adminforth: true
3+
minimumReleaseAgeExclude:
4+
- adminforth

0 commit comments

Comments
 (0)