@adminforth/bulk-ai-flow 1.27.3 → 1.27.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build.log CHANGED
@@ -14,5 +14,5 @@ custom/package.json
14
14
  custom/pnpm-lock.yaml
15
15
  custom/tsconfig.json
16
16
 
17
- sent 130,464 bytes received 191 bytes 261,310.00 bytes/sec
18
- total size is 129,741 speedup is 0.99
17
+ sent 130,414 bytes received 191 bytes 261,210.00 bytes/sec
18
+ total size is 129,691 speedup is 0.99
@@ -884,7 +884,7 @@ async function processOneRecord(recordId: string, sessionIds: string[]) {
884
884
  record.label = oldDataResult._label || record.label;
885
885
  initializeRecordData(record, oldDataResult);
886
886
  if (props.meta.isAttachFiles) {
887
- await fetchImages(record, oldDataResult);
887
+ await fetchImages(record, recordId);
888
888
  if (!checkIfDialogOpen()) {
889
889
  return;
890
890
  }
@@ -1162,16 +1162,14 @@ function normalizeEnumValue(key: string, value: any) {
1162
1162
  return match ? value : null;
1163
1163
  }
1164
1164
 
1165
- async function fetchImages(record: RecordState, oldRecord: Record<string, any>) {
1165
+ async function fetchImages(record: RecordState, recordId: string) {
1166
1166
  try {
1167
1167
  const res = await callAdminForthApi({
1168
1168
  path: `/plugin/${props.meta.pluginInstanceId}/get_images`,
1169
1169
  method: 'POST',
1170
- body: {
1171
- record: [oldRecord],
1172
- },
1170
+ body: { recordId },
1173
1171
  });
1174
- record.images = res.images?.[0] || [];
1172
+ record.images = res.images || [];
1175
1173
  touchRecords();
1176
1174
  } catch (error) {
1177
1175
  console.error('Failed to get images:', error);
@@ -884,7 +884,7 @@ async function processOneRecord(recordId: string, sessionIds: string[]) {
884
884
  record.label = oldDataResult._label || record.label;
885
885
  initializeRecordData(record, oldDataResult);
886
886
  if (props.meta.isAttachFiles) {
887
- await fetchImages(record, oldDataResult);
887
+ await fetchImages(record, recordId);
888
888
  if (!checkIfDialogOpen()) {
889
889
  return;
890
890
  }
@@ -1162,16 +1162,14 @@ function normalizeEnumValue(key: string, value: any) {
1162
1162
  return match ? value : null;
1163
1163
  }
1164
1164
 
1165
- async function fetchImages(record: RecordState, oldRecord: Record<string, any>) {
1165
+ async function fetchImages(record: RecordState, recordId: string) {
1166
1166
  try {
1167
1167
  const res = await callAdminForthApi({
1168
1168
  path: `/plugin/${props.meta.pluginInstanceId}/get_images`,
1169
1169
  method: 'POST',
1170
- body: {
1171
- record: [oldRecord],
1172
- },
1170
+ body: { recordId },
1173
1171
  });
1174
- record.images = res.images?.[0] || [];
1172
+ record.images = res.images || [];
1175
1173
  touchRecords();
1176
1174
  } catch (error) {
1177
1175
  console.error('Failed to get images:', error);
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AdminForthPlugin } from "adminforth";
2
- import type { IAdminForth, IHttpServer, AdminForthResource } from "adminforth";
2
+ import type { IAdminForth, IHttpServer, AdminForthResource, AdminUser } from "adminforth";
3
3
  import type { PluginOptions } from './types.js';
4
4
  import { RateLimiter } from "adminforth";
5
5
  export default class BulkAiFlowPlugin extends AdminForthPlugin {
@@ -26,5 +26,27 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
26
26
  modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource): Promise<void>;
27
27
  validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource): void;
28
28
  instanceUniqueRepresentation(pluginOptions: any): string;
29
+ pluginManagedFields(): Set<string>;
30
+ checkUpdateAllowed({ adminUser, requestBody, recordId, oldRecord, fields }: {
31
+ adminUser: AdminUser;
32
+ requestBody: any;
33
+ recordId: any;
34
+ oldRecord: any;
35
+ fields: Record<string, any>;
36
+ }): Promise<string | null>;
37
+ /**
38
+ * Reads one record the way the core show route does: the resource-level `show` permission first,
39
+ * then the resource's own `show.beforeDatasourceRequest` hooks so row-level scoping narrows the
40
+ * filters before the query runs.
41
+ * The returned record is the raw row, so callers which send it to the browser must pass it
42
+ * through `scopeRecordForFrontend` first.
43
+ */
44
+ private getRecordForShow;
45
+ /**
46
+ * The action dialog only ever renders the fields this plugin generates, so only those leave the
47
+ * backend, next to the primary key and the record label. backendOnly columns are dropped the same
48
+ * way the core show route drops them.
49
+ */
50
+ private scopeRecordForFrontend;
29
51
  setupEndpoints(server: IHttpServer): void;
30
52
  }
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
- import { AdminForthFilterOperators, AdminForthPlugin, Filters } from "adminforth";
10
+ import { AdminForthFilterOperators, AdminForthPlugin, Filters, interpretResource, ActionCheckSource, AllowedActionsEnum } from "adminforth";
11
11
  import { suggestIfTypo, filtersTools } from "adminforth";
12
12
  import Handlebars from 'handlebars';
13
13
  import { RateLimiter } from "adminforth";
@@ -17,7 +17,7 @@ const getRecordsBodySchema = z.object({
17
17
  record: z.array(z.any()).nullish(),
18
18
  }).strict();
19
19
  const getImagesBodySchema = z.object({
20
- record: z.array(z.any()).nullish(),
20
+ recordId: z.union([z.string(), z.number()]),
21
21
  }).strict();
22
22
  const getOldDataBodySchema = z.object({
23
23
  recordId: z.union([z.string(), z.number()]).nullish(),
@@ -28,7 +28,7 @@ const updateFieldsBodySchema = z.object({
28
28
  saveImages: z.boolean().nullish(),
29
29
  }).strict();
30
30
  const getImageGenerationPromptsBodySchema = z.object({
31
- recordId: z.union([z.string(), z.number()]).nullish(),
31
+ recordId: z.union([z.string(), z.number()]),
32
32
  customPrompt: z.string().nullish(),
33
33
  }).strict();
34
34
  const createJobBodySchema = z.object({
@@ -55,6 +55,43 @@ const compileOldImageLinkBodySchema = z.object({
55
55
  const getFilteredIdsBodySchema = z.object({
56
56
  filters: z.any(),
57
57
  }).passthrough();
58
+ function resolveBoolOrFn(val, ctx) {
59
+ return __awaiter(this, void 0, void 0, function* () {
60
+ if (typeof val === 'function') {
61
+ return !!(yield val(ctx));
62
+ }
63
+ return !!val;
64
+ });
65
+ }
66
+ function isShownInEdit(column, ctx) {
67
+ return __awaiter(this, void 0, void 0, function* () {
68
+ const showIn = column.showIn || {};
69
+ if (showIn.edit !== undefined) {
70
+ return yield resolveBoolOrFn(showIn.edit, ctx);
71
+ }
72
+ if (showIn.all !== undefined) {
73
+ return yield resolveBoolOrFn(showIn.all, ctx);
74
+ }
75
+ return true;
76
+ });
77
+ }
78
+ function valueIsUnchanged(newValue, oldValue) {
79
+ if (newValue === oldValue) {
80
+ return true;
81
+ }
82
+ if (newValue === null || newValue === undefined || oldValue === null || oldValue === undefined) {
83
+ return false;
84
+ }
85
+ if (typeof newValue === 'object' || typeof oldValue === 'object') {
86
+ try {
87
+ return JSON.stringify(newValue) === JSON.stringify(oldValue);
88
+ }
89
+ catch (_a) {
90
+ return false;
91
+ }
92
+ }
93
+ return String(newValue) === String(oldValue);
94
+ }
58
95
  const STUB_MODE = false;
59
96
  const jobs = new Map();
60
97
  export default class BulkAiFlowPlugin extends AdminForthPlugin {
@@ -810,44 +847,186 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
810
847
  instanceUniqueRepresentation(pluginOptions) {
811
848
  return `${this.pluginOptions.actionName}`;
812
849
  }
850
+ pluginManagedFields() {
851
+ return new Set([
852
+ ...Object.keys(this.options.fillFieldsFromImages || {}),
853
+ ...Object.keys(this.options.fillPlainFields || {}),
854
+ ...Object.keys(this.options.generateImages || {}),
855
+ ]);
856
+ }
857
+ checkUpdateAllowed(_a) {
858
+ return __awaiter(this, arguments, void 0, function* ({ adminUser, requestBody, recordId, oldRecord, fields }) {
859
+ const meta = { requestBody, newRecord: fields, oldRecord, pk: recordId };
860
+ const ctx = {
861
+ adminUser,
862
+ resource: this.resourceConfig,
863
+ meta,
864
+ source: ActionCheckSource.EditRequest,
865
+ adminforth: this.adminforth,
866
+ };
867
+ const { allowedActions } = yield interpretResource(adminUser, this.resourceConfig, meta, ActionCheckSource.EditRequest, this.adminforth);
868
+ const editAllowed = allowedActions[AllowedActionsEnum.edit];
869
+ if (editAllowed !== true) {
870
+ return typeof editAllowed === 'string' ? editAllowed : 'You are not allowed to edit records in this resource';
871
+ }
872
+ const managedFields = this.pluginManagedFields();
873
+ for (const [fieldName, value] of Object.entries(fields)) {
874
+ if (valueIsUnchanged(value, oldRecord[fieldName])) {
875
+ continue;
876
+ }
877
+ const column = this.resourceConfig.columns.find((col) => col.name === fieldName);
878
+ if (!column) {
879
+ return `Field "${fieldName}" does not exist in resource "${this.resourceConfig.resourceId}"`;
880
+ }
881
+ if (!managedFields.has(fieldName)) {
882
+ return `Field "${fieldName}" cannot be modified, it is not generated by the "${this.options.actionName}" action`;
883
+ }
884
+ if (column.primaryKey) {
885
+ return `Field "${fieldName}" cannot be modified as it is a primary key`;
886
+ }
887
+ if (yield resolveBoolOrFn(column.backendOnly, ctx)) {
888
+ return `Field "${fieldName}" cannot be modified as it is restricted from editing (backendOnly is true).`;
889
+ }
890
+ if (column.editReadonly) {
891
+ return `Field "${fieldName}" cannot be modified as it is restricted from editing (editReadonly is true).`;
892
+ }
893
+ if (!(yield isShownInEdit(column, ctx)) && !column.allowModifyWhenNotShowInEdit) {
894
+ return `Field "${fieldName}" cannot be modified as it is restricted from editing (showIn.edit is false). If you need to allow updating this hidden field, set column.allowModifyWhenNotShowInEdit = true.`;
895
+ }
896
+ }
897
+ if (this.options.isAllowedToSave) {
898
+ const isAllowedToSave = yield this.options.isAllowedToSave({
899
+ record: oldRecord,
900
+ adminUser: adminUser,
901
+ resource: this.resourceConfig,
902
+ });
903
+ if ((isAllowedToSave === null || isAllowedToSave === void 0 ? void 0 : isAllowedToSave.ok) === false) {
904
+ return isAllowedToSave.error;
905
+ }
906
+ }
907
+ return null;
908
+ });
909
+ }
910
+ /**
911
+ * Reads one record the way the core show route does: the resource-level `show` permission first,
912
+ * then the resource's own `show.beforeDatasourceRequest` hooks so row-level scoping narrows the
913
+ * filters before the query runs.
914
+ * The returned record is the raw row, so callers which send it to the browser must pass it
915
+ * through `scopeRecordForFrontend` first.
916
+ */
917
+ getRecordForShow(_a) {
918
+ return __awaiter(this, arguments, void 0, function* ({ adminUser, recordId, extra }) {
919
+ var _b, _c;
920
+ const meta = { requestBody: extra.body, pk: recordId };
921
+ const ctx = {
922
+ adminUser,
923
+ resource: this.resourceConfig,
924
+ meta,
925
+ source: ActionCheckSource.ShowRequest,
926
+ adminforth: this.adminforth,
927
+ };
928
+ const { allowedActions } = yield interpretResource(adminUser, this.resourceConfig, meta, ActionCheckSource.ShowRequest, this.adminforth);
929
+ const showAllowed = allowedActions[AllowedActionsEnum.show];
930
+ if (showAllowed !== true) {
931
+ return { error: typeof showAllowed === 'string' ? showAllowed : 'You are not allowed to view records in this resource' };
932
+ }
933
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
934
+ const hookQuery = {
935
+ resourceId: this.resourceConfig.resourceId,
936
+ source: 'show',
937
+ filters: [Filters.EQ(primaryKeyColumn.name, recordId)],
938
+ limit: 1,
939
+ offset: 0,
940
+ sort: [],
941
+ };
942
+ for (const hook of ((_c = (_b = this.resourceConfig.hooks) === null || _b === void 0 ? void 0 : _b.show) === null || _c === void 0 ? void 0 : _c.beforeDatasourceRequest) || []) {
943
+ const filterTools = filtersTools.get(hookQuery);
944
+ hookQuery.filtersTools = filterTools;
945
+ const resp = yield hook({
946
+ resource: this.resourceConfig,
947
+ query: hookQuery,
948
+ adminUser,
949
+ //@ts-ignore
950
+ filtersTools: filterTools,
951
+ extra,
952
+ adminforth: this.adminforth,
953
+ });
954
+ if (!resp || (!resp.ok && !resp.error)) {
955
+ throw new Error(`Hook must return object with {ok: true} or { error: 'Error' } `);
956
+ }
957
+ if (resp.error) {
958
+ return { error: resp.error };
959
+ }
960
+ }
961
+ const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get(hookQuery.filters);
962
+ if (!record) {
963
+ return { error: 'Record not found' };
964
+ }
965
+ return { record, ctx };
966
+ });
967
+ }
968
+ /**
969
+ * The action dialog only ever renders the fields this plugin generates, so only those leave the
970
+ * backend, next to the primary key and the record label. backendOnly columns are dropped the same
971
+ * way the core show route drops them.
972
+ */
973
+ scopeRecordForFrontend(record, ctx) {
974
+ return __awaiter(this, void 0, void 0, function* () {
975
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
976
+ const managedFields = this.pluginManagedFields();
977
+ const scoped = {
978
+ [primaryKeyColumn.name]: record[primaryKeyColumn.name],
979
+ _label: this.resourceConfig.recordLabel(record),
980
+ };
981
+ for (const column of this.resourceConfig.columns.filter((col) => managedFields.has(col.name))) {
982
+ if (yield resolveBoolOrFn(column.backendOnly, ctx)) {
983
+ continue;
984
+ }
985
+ scoped[column.name] = record[column.name];
986
+ }
987
+ return scoped;
988
+ });
989
+ }
813
990
  setupEndpoints(server) {
814
991
  server.endpoint({
815
992
  method: 'POST',
816
993
  path: `/plugin/${this.pluginInstanceId}/get_old_data`,
817
994
  request_schema: getOldDataBodySchema,
818
- handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, response }) {
995
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers, query, cookies, requestUrl }) {
819
996
  const data = body;
820
997
  const recordId = data.recordId;
821
998
  if (recordId === undefined || recordId === null) {
822
999
  return { ok: false, error: "Missing recordId" };
823
1000
  }
824
- const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
825
- const record = yield this.adminforth.resource(this.resourceConfig.resourceId)
826
- .get([Filters.EQ(primaryKeyColumn.name, recordId)]);
827
- if (!record) {
828
- return { ok: false, error: "Record not found" };
1001
+ const { record, ctx, error } = yield this.getRecordForShow({
1002
+ adminUser,
1003
+ recordId,
1004
+ extra: { body, query, headers, cookies, requestUrl },
1005
+ });
1006
+ if (error) {
1007
+ return { ok: false, error };
829
1008
  }
830
- record._label = this.resourceConfig.recordLabel(record);
831
- return { ok: true, record };
1009
+ return { ok: true, record: yield this.scopeRecordForFrontend(record, ctx) };
832
1010
  })
833
1011
  });
834
1012
  server.endpoint({
835
1013
  method: 'POST',
836
1014
  path: `/plugin/${this.pluginInstanceId}/get_images`,
837
1015
  request_schema: getImagesBodySchema,
838
- handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, response }) {
1016
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers, query, cookies, requestUrl }) {
839
1017
  const data = body;
840
- let images = [];
841
- if (data.record) {
842
- for (const record of data.record) {
843
- if (this.options.attachFiles) {
844
- images.push(yield this.options.attachFiles({ record: record }));
845
- }
846
- }
1018
+ // attachFiles runs with server privileges and usually mints download URLs, so it is only
1019
+ // ever given a record loaded here after the show check, never one supplied by the client
1020
+ const { record, error } = yield this.getRecordForShow({
1021
+ adminUser,
1022
+ recordId: data.recordId,
1023
+ extra: { body, query, headers, cookies, requestUrl },
1024
+ });
1025
+ if (error) {
1026
+ return { ok: false, error };
847
1027
  }
848
- return {
849
- images,
850
- };
1028
+ const images = this.options.attachFiles ? yield this.options.attachFiles({ record }) : [];
1029
+ return { ok: true, images };
851
1030
  })
852
1031
  });
853
1032
  server.endpoint({
@@ -856,113 +1035,138 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
856
1035
  request_schema: updateFieldsBodySchema,
857
1036
  handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers, response }) {
858
1037
  const data = body;
859
- let isAllowedToSave = { ok: true, error: '' };
860
- if (this.options.isAllowedToSave) {
861
- isAllowedToSave = yield this.options.isAllowedToSave({ record: {}, adminUser: adminUser, resource: this.resourceConfig });
862
- }
863
- if (isAllowedToSave.ok !== false) {
864
- const selectedIds = data.selectedIds || [];
865
- const fieldsToUpdate = data.fields || {};
866
- const saveImages = data.saveImages;
867
- const outputImageFields = [];
868
- if (this.options.generateImages) {
869
- for (const [key, value] of Object.entries(this.options.generateImages)) {
870
- outputImageFields.push(key);
871
- }
1038
+ const selectedIds = data.selectedIds || [];
1039
+ const fieldsToUpdate = data.fields || {};
1040
+ const saveImages = data.saveImages;
1041
+ const outputImageFields = [];
1042
+ if (this.options.generateImages) {
1043
+ for (const [key, value] of Object.entries(this.options.generateImages)) {
1044
+ outputImageFields.push(key);
872
1045
  }
873
- const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
874
- const decimalFieldsArray = this.resourceConfig.columns.filter(c => c.type === 'decimal').map(c => c.name);
875
- const integerFieldsArray = this.resourceConfig.columns.filter(c => c.type === 'integer').map(c => c.name);
876
- const floatFieldsArray = this.resourceConfig.columns.filter(c => c.type === 'float').map(c => c.name);
877
- const updates = selectedIds.map((ID, idx) => __awaiter(this, void 0, void 0, function* () {
878
- const oldRecord = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(primaryKeyColumn.name, ID)]);
879
- for (const [key, value] of Object.entries(outputImageFields)) {
880
- const columnPlugin = this.adminforth.activatedPlugins.find(p => p.resourceConfig.resourceId === this.resourceConfig.resourceId &&
881
- p.pluginOptions.pathColumnName === value);
882
- if (columnPlugin && saveImages) {
883
- if (columnPlugin.pluginOptions.storageAdapter.objectCanBeAccesedPublicly()) {
884
- if (oldRecord[value]) {
885
- // put tag to delete old file
886
- try {
887
- if (columnPlugin.pluginOptions.storageAdapter.markKeyForDeletion !== undefined) {
888
- yield columnPlugin.pluginOptions.storageAdapter.markKeyForDeletion(oldRecord[value]);
889
- }
890
- else {
891
- yield columnPlugin.pluginOptions.storageAdapter.markKeyForDeletation(oldRecord[value]);
892
- }
893
- }
894
- catch (e) {
895
- // file might be e.g. already deleted, so we catch error
896
- console.error(`Error setting tag to true for object ${oldRecord[value]}. File will not be auto-cleaned up`);
897
- }
898
- }
899
- if (fieldsToUpdate[idx][value] && fieldsToUpdate[idx][value] !== null) {
900
- // remove tag from new file
901
- // in this case we let it crash if it fails: this is a new file which just was uploaded.
902
- if (columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletion !== undefined) {
903
- yield columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletion(fieldsToUpdate[idx][value]);
1046
+ }
1047
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
1048
+ const decimalFieldsArray = this.resourceConfig.columns.filter(c => c.type === 'decimal').map(c => c.name);
1049
+ const integerFieldsArray = this.resourceConfig.columns.filter(c => c.type === 'integer').map(c => c.name);
1050
+ const floatFieldsArray = this.resourceConfig.columns.filter(c => c.type === 'float').map(c => c.name);
1051
+ // authorize every record before touching anything: this endpoint writes to the same
1052
+ // table as the core edit route, so it has to enforce the same permissions
1053
+ const managedFields = this.pluginManagedFields();
1054
+ const oldRecords = [];
1055
+ const sanitizedFields = [];
1056
+ for (const [idx, ID] of selectedIds.entries()) {
1057
+ const oldRecord = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(primaryKeyColumn.name, ID)]);
1058
+ if (!oldRecord) {
1059
+ return { ok: false, error: `Record with ${primaryKeyColumn.name} ${ID} not found` };
1060
+ }
1061
+ const fields = fieldsToUpdate[idx];
1062
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
1063
+ return { ok: false, error: `No fields to update passed for record ${ID}` };
1064
+ }
1065
+ const accessError = yield this.checkUpdateAllowed({
1066
+ adminUser,
1067
+ requestBody: body,
1068
+ recordId: ID,
1069
+ oldRecord,
1070
+ fields,
1071
+ });
1072
+ if (accessError) {
1073
+ return { ok: false, error: accessError };
1074
+ }
1075
+ oldRecords.push(oldRecord);
1076
+ // everything else (e.g. the primary key which frontend echoes back) is never written
1077
+ sanitizedFields.push(Object.fromEntries(Object.entries(fields).filter(([fieldName]) => managedFields.has(fieldName))));
1078
+ }
1079
+ const updates = selectedIds.map((ID, idx) => __awaiter(this, void 0, void 0, function* () {
1080
+ const oldRecord = oldRecords[idx];
1081
+ const recordFields = sanitizedFields[idx];
1082
+ for (const [key, value] of Object.entries(outputImageFields)) {
1083
+ const columnPlugin = this.adminforth.activatedPlugins.find(p => p.resourceConfig.resourceId === this.resourceConfig.resourceId &&
1084
+ p.pluginOptions.pathColumnName === value);
1085
+ if (columnPlugin && saveImages) {
1086
+ if (columnPlugin.pluginOptions.storageAdapter.objectCanBeAccesedPublicly()) {
1087
+ if (oldRecord[value]) {
1088
+ // put tag to delete old file
1089
+ try {
1090
+ if (columnPlugin.pluginOptions.storageAdapter.markKeyForDeletion !== undefined) {
1091
+ yield columnPlugin.pluginOptions.storageAdapter.markKeyForDeletion(oldRecord[value]);
904
1092
  }
905
1093
  else {
906
- yield columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletation(fieldsToUpdate[idx][value]);
1094
+ yield columnPlugin.pluginOptions.storageAdapter.markKeyForDeletation(oldRecord[value]);
907
1095
  }
908
1096
  }
1097
+ catch (e) {
1098
+ // file might be e.g. already deleted, so we catch error
1099
+ console.error(`Error setting tag to true for object ${oldRecord[value]}. File will not be auto-cleaned up`);
1100
+ }
909
1101
  }
910
- }
911
- }
912
- // Convert decimal fields to numbers
913
- if (decimalFieldsArray.length > 0) {
914
- for (const fieldName of decimalFieldsArray) {
915
- if (fieldsToUpdate[idx].hasOwnProperty(fieldName)) {
916
- fieldsToUpdate[idx][fieldName] = `${fieldsToUpdate[idx][fieldName]}`;
1102
+ if (recordFields[value] && recordFields[value] !== null) {
1103
+ // remove tag from new file
1104
+ // in this case we let it crash if it fails: this is a new file which just was uploaded.
1105
+ if (columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletion !== undefined) {
1106
+ yield columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletion(recordFields[value]);
1107
+ }
1108
+ else {
1109
+ yield columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletation(recordFields[value]);
1110
+ }
917
1111
  }
918
1112
  }
919
1113
  }
920
- if (integerFieldsArray.length > 0) {
921
- for (const fieldName of integerFieldsArray) {
922
- if (fieldsToUpdate[idx].hasOwnProperty(fieldName)) {
923
- fieldsToUpdate[idx][fieldName] = parseInt(fieldsToUpdate[idx][fieldName], 10);
924
- }
1114
+ }
1115
+ // Convert decimal fields to numbers
1116
+ if (decimalFieldsArray.length > 0) {
1117
+ for (const fieldName of decimalFieldsArray) {
1118
+ if (recordFields.hasOwnProperty(fieldName)) {
1119
+ recordFields[fieldName] = `${recordFields[fieldName]}`;
925
1120
  }
926
1121
  }
927
- if (floatFieldsArray.length > 0) {
928
- for (const fieldName of floatFieldsArray) {
929
- if (fieldsToUpdate[idx].hasOwnProperty(fieldName)) {
930
- fieldsToUpdate[idx][fieldName] = parseFloat(fieldsToUpdate[idx][fieldName]);
931
- }
1122
+ }
1123
+ if (integerFieldsArray.length > 0) {
1124
+ for (const fieldName of integerFieldsArray) {
1125
+ if (recordFields.hasOwnProperty(fieldName)) {
1126
+ recordFields[fieldName] = parseInt(recordFields[fieldName], 10);
932
1127
  }
933
1128
  }
934
- const newRecord = Object.assign(Object.assign({}, oldRecord), fieldsToUpdate[idx]);
935
- return this.adminforth.updateResourceRecord({
936
- resource: this.resourceConfig,
937
- recordId: ID,
938
- oldRecord: oldRecord,
939
- updates: newRecord,
940
- adminUser: adminUser,
941
- });
942
- }));
943
- try {
944
- yield Promise.all(updates);
945
1129
  }
946
- catch (error) {
947
- return { ok: false, error: `Error updating records, because of unprocesseble data for record ID ${selectedIds}` };
1130
+ if (floatFieldsArray.length > 0) {
1131
+ for (const fieldName of floatFieldsArray) {
1132
+ if (recordFields.hasOwnProperty(fieldName)) {
1133
+ recordFields[fieldName] = parseFloat(recordFields[fieldName]);
1134
+ }
1135
+ }
948
1136
  }
949
- return { ok: true };
1137
+ const newRecord = Object.assign(Object.assign({}, oldRecord), recordFields);
1138
+ return this.adminforth.updateResourceRecord({
1139
+ resource: this.resourceConfig,
1140
+ recordId: ID,
1141
+ oldRecord: oldRecord,
1142
+ updates: newRecord,
1143
+ adminUser: adminUser,
1144
+ });
1145
+ }));
1146
+ try {
1147
+ yield Promise.all(updates);
950
1148
  }
951
- else {
952
- return { ok: false, error: isAllowedToSave.error };
1149
+ catch (error) {
1150
+ return { ok: false, error: `Error updating records, because of unprocesseble data for record ID ${selectedIds}` };
953
1151
  }
1152
+ return { ok: true };
954
1153
  })
955
1154
  });
956
1155
  server.endpoint({
957
1156
  method: 'POST',
958
1157
  path: `/plugin/${this.pluginInstanceId}/get_image_generation_prompts`,
959
1158
  request_schema: getImageGenerationPromptsBodySchema,
960
- handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, headers, response }) {
961
- var _b;
1159
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers, query, cookies, requestUrl }) {
962
1160
  const data = body;
963
- const Id = data.recordId || [];
964
1161
  const customPrompt = data.customPrompt || null;
965
- const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ((_b = this.resourceConfig.columns.find(c => c.primaryKey)) === null || _b === void 0 ? void 0 : _b.name, Id)]);
1162
+ const { record, error } = yield this.getRecordForShow({
1163
+ adminUser,
1164
+ recordId: data.recordId,
1165
+ extra: { body, query, headers, cookies, requestUrl },
1166
+ });
1167
+ if (error) {
1168
+ return { ok: false, error };
1169
+ }
966
1170
  const compiledGenerationOptions = yield this.compileGenerationFieldTemplates(record, JSON.stringify({ "prompt": customPrompt }));
967
1171
  return compiledGenerationOptions;
968
1172
  })
@@ -1141,6 +1345,11 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1141
1345
  handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers, query, cookies, requestUrl, response }) {
1142
1346
  var _b, _c;
1143
1347
  const resource = this.resourceConfig;
1348
+ const { allowedActions } = yield interpretResource(adminUser, resource, { requestBody: body }, ActionCheckSource.ListRequest, this.adminforth);
1349
+ const listAllowed = allowedActions[AllowedActionsEnum.list];
1350
+ if (listAllowed !== true) {
1351
+ return { ok: false, error: typeof listAllowed === 'string' ? listAllowed : 'You are not allowed to list records in this resource' };
1352
+ }
1144
1353
  for (const hook of ((_c = (_b = resource.hooks) === null || _b === void 0 ? void 0 : _b.list) === null || _c === void 0 ? void 0 : _c.beforeDatasourceRequest) || []) {
1145
1354
  const filterTools = filtersTools.get(body);
1146
1355
  body.filtersTools = filterTools;
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { AdminForthFilterOperators, AdminForthPlugin, Filters } from "adminforth";
2
- import type { IAdminForth, IHttpServer, AdminForthComponentDeclaration, AdminForthResource } from "adminforth";
1
+ import { AdminForthFilterOperators, AdminForthPlugin, Filters, interpretResource, ActionCheckSource, AllowedActionsEnum } from "adminforth";
2
+ import type { IAdminForth, IHttpServer, AdminForthComponentDeclaration, AdminForthResource, AdminUser } from "adminforth";
3
3
  import { suggestIfTypo, filtersTools } from "adminforth";
4
4
  import type { PluginOptions } from './types.js';
5
5
  import Handlebars from 'handlebars';
@@ -12,7 +12,7 @@ const getRecordsBodySchema = z.object({
12
12
  }).strict();
13
13
 
14
14
  const getImagesBodySchema = z.object({
15
- record: z.array(z.any()).nullish(),
15
+ recordId: z.union([z.string(), z.number()]),
16
16
  }).strict();
17
17
 
18
18
  const getOldDataBodySchema = z.object({
@@ -26,7 +26,7 @@ const updateFieldsBodySchema = z.object({
26
26
  }).strict();
27
27
 
28
28
  const getImageGenerationPromptsBodySchema = z.object({
29
- recordId: z.union([z.string(), z.number()]).nullish(),
29
+ recordId: z.union([z.string(), z.number()]),
30
30
  customPrompt: z.string().nullish(),
31
31
  }).strict();
32
32
 
@@ -58,6 +58,49 @@ const compileOldImageLinkBodySchema = z.object({
58
58
  const getFilteredIdsBodySchema = z.object({
59
59
  filters: z.any(),
60
60
  }).passthrough();
61
+
62
+ type AccessCheckContext = {
63
+ adminUser: AdminUser;
64
+ resource: AdminForthResource;
65
+ meta: any;
66
+ source: ActionCheckSource;
67
+ adminforth: IAdminForth;
68
+ };
69
+
70
+ async function resolveBoolOrFn(val: any, ctx: AccessCheckContext): Promise<boolean> {
71
+ if (typeof val === 'function') {
72
+ return !!(await val(ctx));
73
+ }
74
+ return !!val;
75
+ }
76
+
77
+ async function isShownInEdit(column: AdminForthResource['columns'][number], ctx: AccessCheckContext): Promise<boolean> {
78
+ const showIn = (column.showIn as any) || {};
79
+ if (showIn.edit !== undefined) {
80
+ return await resolveBoolOrFn(showIn.edit, ctx);
81
+ }
82
+ if (showIn.all !== undefined) {
83
+ return await resolveBoolOrFn(showIn.all, ctx);
84
+ }
85
+ return true;
86
+ }
87
+
88
+ function valueIsUnchanged(newValue: any, oldValue: any): boolean {
89
+ if (newValue === oldValue) {
90
+ return true;
91
+ }
92
+ if (newValue === null || newValue === undefined || oldValue === null || oldValue === undefined) {
93
+ return false;
94
+ }
95
+ if (typeof newValue === 'object' || typeof oldValue === 'object') {
96
+ try {
97
+ return JSON.stringify(newValue) === JSON.stringify(oldValue);
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+ return String(newValue) === String(oldValue);
103
+ }
61
104
  const STUB_MODE = false;
62
105
  const jobs = new Map();
63
106
  export default class BulkAiFlowPlugin extends AdminForthPlugin {
@@ -828,25 +871,194 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
828
871
  return `${this.pluginOptions.actionName}`;
829
872
  }
830
873
 
874
+ pluginManagedFields(): Set<string> {
875
+ return new Set([
876
+ ...Object.keys(this.options.fillFieldsFromImages || {}),
877
+ ...Object.keys(this.options.fillPlainFields || {}),
878
+ ...Object.keys(this.options.generateImages || {}),
879
+ ]);
880
+ }
881
+
882
+
883
+ async checkUpdateAllowed({ adminUser, requestBody, recordId, oldRecord, fields }: {
884
+ adminUser: AdminUser,
885
+ requestBody: any,
886
+ recordId: any,
887
+ oldRecord: any,
888
+ fields: Record<string, any>,
889
+ }): Promise<string | null> {
890
+ const meta = { requestBody, newRecord: fields, oldRecord, pk: recordId };
891
+ const ctx: AccessCheckContext = {
892
+ adminUser,
893
+ resource: this.resourceConfig,
894
+ meta,
895
+ source: ActionCheckSource.EditRequest,
896
+ adminforth: this.adminforth,
897
+ };
898
+
899
+ const { allowedActions } = await interpretResource(
900
+ adminUser,
901
+ this.resourceConfig,
902
+ meta,
903
+ ActionCheckSource.EditRequest,
904
+ this.adminforth,
905
+ );
906
+ const editAllowed = allowedActions[AllowedActionsEnum.edit] as boolean | string | undefined;
907
+ if (editAllowed !== true) {
908
+ return typeof editAllowed === 'string' ? editAllowed : 'You are not allowed to edit records in this resource';
909
+ }
910
+
911
+ const managedFields = this.pluginManagedFields();
912
+
913
+ for (const [fieldName, value] of Object.entries(fields)) {
914
+ if (valueIsUnchanged(value, oldRecord[fieldName])) {
915
+ continue;
916
+ }
917
+ const column = this.resourceConfig.columns.find((col) => col.name === fieldName);
918
+ if (!column) {
919
+ return `Field "${fieldName}" does not exist in resource "${this.resourceConfig.resourceId}"`;
920
+ }
921
+ if (!managedFields.has(fieldName)) {
922
+ return `Field "${fieldName}" cannot be modified, it is not generated by the "${this.options.actionName}" action`;
923
+ }
924
+ if (column.primaryKey) {
925
+ return `Field "${fieldName}" cannot be modified as it is a primary key`;
926
+ }
927
+ if (await resolveBoolOrFn(column.backendOnly, ctx)) {
928
+ return `Field "${fieldName}" cannot be modified as it is restricted from editing (backendOnly is true).`;
929
+ }
930
+ if (column.editReadonly) {
931
+ return `Field "${fieldName}" cannot be modified as it is restricted from editing (editReadonly is true).`;
932
+ }
933
+ if (!(await isShownInEdit(column, ctx)) && !column.allowModifyWhenNotShowInEdit) {
934
+ return `Field "${fieldName}" cannot be modified as it is restricted from editing (showIn.edit is false). If you need to allow updating this hidden field, set column.allowModifyWhenNotShowInEdit = true.`;
935
+ }
936
+ }
937
+
938
+ if (this.options.isAllowedToSave) {
939
+ const isAllowedToSave = await this.options.isAllowedToSave({
940
+ record: oldRecord,
941
+ adminUser: adminUser,
942
+ resource: this.resourceConfig,
943
+ });
944
+ if (isAllowedToSave?.ok === false) {
945
+ return isAllowedToSave.error;
946
+ }
947
+ }
948
+
949
+ return null;
950
+ }
951
+
952
+ /**
953
+ * Reads one record the way the core show route does: the resource-level `show` permission first,
954
+ * then the resource's own `show.beforeDatasourceRequest` hooks so row-level scoping narrows the
955
+ * filters before the query runs.
956
+ * The returned record is the raw row, so callers which send it to the browser must pass it
957
+ * through `scopeRecordForFrontend` first.
958
+ */
959
+ private async getRecordForShow({ adminUser, recordId, extra }: {
960
+ adminUser: AdminUser,
961
+ recordId: any,
962
+ extra: { body: any, query: any, headers: any, cookies: any, requestUrl: string },
963
+ }): Promise<{ record?: any, ctx?: AccessCheckContext, error?: string }> {
964
+ const meta = { requestBody: extra.body, pk: recordId };
965
+ const ctx: AccessCheckContext = {
966
+ adminUser,
967
+ resource: this.resourceConfig,
968
+ meta,
969
+ source: ActionCheckSource.ShowRequest,
970
+ adminforth: this.adminforth,
971
+ };
972
+
973
+ const { allowedActions } = await interpretResource(
974
+ adminUser,
975
+ this.resourceConfig,
976
+ meta,
977
+ ActionCheckSource.ShowRequest,
978
+ this.adminforth,
979
+ );
980
+ const showAllowed = allowedActions[AllowedActionsEnum.show] as boolean | string | undefined;
981
+ if (showAllowed !== true) {
982
+ return { error: typeof showAllowed === 'string' ? showAllowed : 'You are not allowed to view records in this resource' };
983
+ }
984
+
985
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
986
+ const hookQuery: any = {
987
+ resourceId: this.resourceConfig.resourceId,
988
+ source: 'show',
989
+ filters: [Filters.EQ(primaryKeyColumn.name, recordId)],
990
+ limit: 1,
991
+ offset: 0,
992
+ sort: [],
993
+ };
994
+ for (const hook of this.resourceConfig.hooks?.show?.beforeDatasourceRequest || []) {
995
+ const filterTools = filtersTools.get(hookQuery);
996
+ hookQuery.filtersTools = filterTools;
997
+ const resp = await hook({
998
+ resource: this.resourceConfig,
999
+ query: hookQuery,
1000
+ adminUser,
1001
+ //@ts-ignore
1002
+ filtersTools: filterTools,
1003
+ extra,
1004
+ adminforth: this.adminforth,
1005
+ });
1006
+ if (!resp || (!resp.ok && !resp.error)) {
1007
+ throw new Error(`Hook must return object with {ok: true} or { error: 'Error' } `);
1008
+ }
1009
+ if (resp.error) {
1010
+ return { error: resp.error };
1011
+ }
1012
+ }
1013
+
1014
+ const record = await this.adminforth.resource(this.resourceConfig.resourceId).get(hookQuery.filters);
1015
+ if (!record) {
1016
+ return { error: 'Record not found' };
1017
+ }
1018
+ return { record, ctx };
1019
+ }
1020
+
1021
+ /**
1022
+ * The action dialog only ever renders the fields this plugin generates, so only those leave the
1023
+ * backend, next to the primary key and the record label. backendOnly columns are dropped the same
1024
+ * way the core show route drops them.
1025
+ */
1026
+ private async scopeRecordForFrontend(record: any, ctx: AccessCheckContext): Promise<Record<string, any>> {
1027
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
1028
+ const managedFields = this.pluginManagedFields();
1029
+ const scoped: Record<string, any> = {
1030
+ [primaryKeyColumn.name]: record[primaryKeyColumn.name],
1031
+ _label: this.resourceConfig.recordLabel(record),
1032
+ };
1033
+ for (const column of this.resourceConfig.columns.filter((col) => managedFields.has(col.name))) {
1034
+ if (await resolveBoolOrFn(column.backendOnly, ctx)) {
1035
+ continue;
1036
+ }
1037
+ scoped[column.name] = record[column.name];
1038
+ }
1039
+ return scoped;
1040
+ }
1041
+
831
1042
  setupEndpoints(server: IHttpServer) {
832
1043
  server.endpoint({
833
1044
  method: 'POST',
834
1045
  path: `/plugin/${this.pluginInstanceId}/get_old_data`,
835
1046
  request_schema: getOldDataBodySchema,
836
- handler: async ({ body, response }) => {
1047
+ handler: async ({ body, adminUser, headers, query, cookies, requestUrl }) => {
837
1048
  const data = body as z.infer<typeof getOldDataBodySchema>;
838
1049
  const recordId = data.recordId;
839
1050
  if (recordId === undefined || recordId === null) {
840
1051
  return { ok: false, error: "Missing recordId" };
841
1052
  }
842
- const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
843
- const record = await this.adminforth.resource(this.resourceConfig.resourceId)
844
- .get([Filters.EQ(primaryKeyColumn.name, recordId)]);
845
- if (!record) {
846
- return { ok: false, error: "Record not found" };
1053
+ const { record, ctx, error } = await this.getRecordForShow({
1054
+ adminUser,
1055
+ recordId,
1056
+ extra: { body, query, headers, cookies, requestUrl },
1057
+ });
1058
+ if (error) {
1059
+ return { ok: false, error };
847
1060
  }
848
- record._label = this.resourceConfig.recordLabel(record);
849
- return { ok: true, record };
1061
+ return { ok: true, record: await this.scopeRecordForFrontend(record, ctx) };
850
1062
  }
851
1063
  });
852
1064
 
@@ -855,19 +1067,20 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
855
1067
  method: 'POST',
856
1068
  path: `/plugin/${this.pluginInstanceId}/get_images`,
857
1069
  request_schema: getImagesBodySchema,
858
- handler: async ({ body, response }) => {
1070
+ handler: async ({ body, adminUser, headers, query, cookies, requestUrl }) => {
859
1071
  const data = body as z.infer<typeof getImagesBodySchema>;
860
- let images = [];
861
- if(data.record){
862
- for( const record of data.record ) {
863
- if (this.options.attachFiles) {
864
- images.push(await this.options.attachFiles({ record: record }));
865
- }
866
- }
1072
+ // attachFiles runs with server privileges and usually mints download URLs, so it is only
1073
+ // ever given a record loaded here after the show check, never one supplied by the client
1074
+ const { record, error } = await this.getRecordForShow({
1075
+ adminUser,
1076
+ recordId: data.recordId,
1077
+ extra: { body, query, headers, cookies, requestUrl },
1078
+ });
1079
+ if (error) {
1080
+ return { ok: false, error };
867
1081
  }
868
- return {
869
- images,
870
- };
1082
+ const images = this.options.attachFiles ? await this.options.attachFiles({ record }) : [];
1083
+ return { ok: true, images };
871
1084
  }
872
1085
  });
873
1086
 
@@ -878,105 +1091,129 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
878
1091
  request_schema: updateFieldsBodySchema,
879
1092
  handler: async ({ body, adminUser, headers, response }) => {
880
1093
  const data = body as z.infer<typeof updateFieldsBodySchema>;
881
- let isAllowedToSave: any = { ok: true, error: '' };
882
- if(this.options.isAllowedToSave) {
883
- isAllowedToSave = await this.options.isAllowedToSave({ record: {}, adminUser: adminUser, resource: this.resourceConfig });
1094
+ const selectedIds = data.selectedIds || [];
1095
+ const fieldsToUpdate = data.fields || {};
1096
+ const saveImages = data.saveImages;
1097
+ const outputImageFields = [];
1098
+ if (this.options.generateImages) {
1099
+ for (const [key, value] of Object.entries(this.options.generateImages)) {
1100
+ outputImageFields.push(key);
1101
+ }
884
1102
  }
885
- if (isAllowedToSave.ok !== false) {
886
- const selectedIds = data.selectedIds || [];
887
- const fieldsToUpdate = data.fields || {};
888
- const saveImages = data.saveImages;
889
- const outputImageFields = [];
890
- if (this.options.generateImages) {
891
- for (const [key, value] of Object.entries(this.options.generateImages)) {
892
- outputImageFields.push(key);
893
- }
1103
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
1104
+
1105
+ const decimalFieldsArray = this.resourceConfig.columns.filter(c => c.type === 'decimal').map(c => c.name);
1106
+ const integerFieldsArray = this.resourceConfig.columns.filter(c => c.type === 'integer').map(c => c.name);
1107
+ const floatFieldsArray = this.resourceConfig.columns.filter(c => c.type === 'float').map(c => c.name);
1108
+
1109
+ // authorize every record before touching anything: this endpoint writes to the same
1110
+ // table as the core edit route, so it has to enforce the same permissions
1111
+ const managedFields = this.pluginManagedFields();
1112
+ const oldRecords: any[] = [];
1113
+ const sanitizedFields: Record<string, any>[] = [];
1114
+ for (const [idx, ID] of selectedIds.entries()) {
1115
+ const oldRecord = await this.adminforth.resource(this.resourceConfig.resourceId).get( [Filters.EQ(primaryKeyColumn.name, ID)] );
1116
+ if (!oldRecord) {
1117
+ return { ok: false, error: `Record with ${primaryKeyColumn.name} ${ID} not found` };
894
1118
  }
895
- const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
896
-
897
- const decimalFieldsArray = this.resourceConfig.columns.filter(c => c.type === 'decimal').map(c => c.name);
898
- const integerFieldsArray = this.resourceConfig.columns.filter(c => c.type === 'integer').map(c => c.name);
899
- const floatFieldsArray = this.resourceConfig.columns.filter(c => c.type === 'float').map(c => c.name);
900
-
901
- const updates = selectedIds.map(async (ID, idx) => {
902
- const oldRecord = await this.adminforth.resource(this.resourceConfig.resourceId).get( [Filters.EQ(primaryKeyColumn.name, ID)] );
903
- for (const [key, value] of Object.entries(outputImageFields)) {
904
- const columnPlugin = this.adminforth.activatedPlugins.find(p =>
905
- p.resourceConfig!.resourceId === this.resourceConfig.resourceId &&
906
- p.pluginOptions.pathColumnName === value
907
- );
908
- if (columnPlugin && saveImages) {
909
- if(columnPlugin.pluginOptions.storageAdapter.objectCanBeAccesedPublicly()) {
910
- if (oldRecord[value]) {
911
- // put tag to delete old file
912
- try {
913
- if (columnPlugin.pluginOptions.storageAdapter.markKeyForDeletion !== undefined) {
914
- await columnPlugin.pluginOptions.storageAdapter.markKeyForDeletion(oldRecord[value]);
915
- } else {
916
- await columnPlugin.pluginOptions.storageAdapter.markKeyForDeletation(oldRecord[value]);
917
- }
918
- } catch (e) {
919
- // file might be e.g. already deleted, so we catch error
920
- console.error(`Error setting tag to true for object ${oldRecord[value]}. File will not be auto-cleaned up`);
921
- }
922
- }
923
- if (fieldsToUpdate[idx][value] && fieldsToUpdate[idx][value] !== null) {
924
- // remove tag from new file
925
- // in this case we let it crash if it fails: this is a new file which just was uploaded.
926
- if (columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletion !== undefined) {
927
- await columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletion(fieldsToUpdate[idx][value]);
1119
+ const fields = fieldsToUpdate[idx];
1120
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
1121
+ return { ok: false, error: `No fields to update passed for record ${ID}` };
1122
+ }
1123
+ const accessError = await this.checkUpdateAllowed({
1124
+ adminUser,
1125
+ requestBody: body,
1126
+ recordId: ID,
1127
+ oldRecord,
1128
+ fields,
1129
+ });
1130
+ if (accessError) {
1131
+ return { ok: false, error: accessError };
1132
+ }
1133
+ oldRecords.push(oldRecord);
1134
+ // everything else (e.g. the primary key which frontend echoes back) is never written
1135
+ sanitizedFields.push(Object.fromEntries(
1136
+ Object.entries(fields).filter(([fieldName]) => managedFields.has(fieldName))
1137
+ ));
1138
+ }
1139
+
1140
+ const updates = selectedIds.map(async (ID, idx) => {
1141
+ const oldRecord = oldRecords[idx];
1142
+ const recordFields = sanitizedFields[idx];
1143
+ for (const [key, value] of Object.entries(outputImageFields)) {
1144
+ const columnPlugin = this.adminforth.activatedPlugins.find(p =>
1145
+ p.resourceConfig!.resourceId === this.resourceConfig.resourceId &&
1146
+ p.pluginOptions.pathColumnName === value
1147
+ );
1148
+ if (columnPlugin && saveImages) {
1149
+ if(columnPlugin.pluginOptions.storageAdapter.objectCanBeAccesedPublicly()) {
1150
+ if (oldRecord[value]) {
1151
+ // put tag to delete old file
1152
+ try {
1153
+ if (columnPlugin.pluginOptions.storageAdapter.markKeyForDeletion !== undefined) {
1154
+ await columnPlugin.pluginOptions.storageAdapter.markKeyForDeletion(oldRecord[value]);
928
1155
  } else {
929
- await columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletation(fieldsToUpdate[idx][value]);
1156
+ await columnPlugin.pluginOptions.storageAdapter.markKeyForDeletation(oldRecord[value]);
930
1157
  }
1158
+ } catch (e) {
1159
+ // file might be e.g. already deleted, so we catch error
1160
+ console.error(`Error setting tag to true for object ${oldRecord[value]}. File will not be auto-cleaned up`);
1161
+ }
1162
+ }
1163
+ if (recordFields[value] && recordFields[value] !== null) {
1164
+ // remove tag from new file
1165
+ // in this case we let it crash if it fails: this is a new file which just was uploaded.
1166
+ if (columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletion !== undefined) {
1167
+ await columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletion(recordFields[value]);
1168
+ } else {
1169
+ await columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletation(recordFields[value]);
931
1170
  }
932
1171
  }
933
1172
  }
934
1173
  }
1174
+ }
935
1175
 
936
- // Convert decimal fields to numbers
937
- if (decimalFieldsArray.length > 0) {
938
- for (const fieldName of decimalFieldsArray) {
939
- if (fieldsToUpdate[idx].hasOwnProperty(fieldName)) {
940
- fieldsToUpdate[idx][fieldName] = `${fieldsToUpdate[idx][fieldName]}`;
941
- }
1176
+ // Convert decimal fields to numbers
1177
+ if (decimalFieldsArray.length > 0) {
1178
+ for (const fieldName of decimalFieldsArray) {
1179
+ if (recordFields.hasOwnProperty(fieldName)) {
1180
+ recordFields[fieldName] = `${recordFields[fieldName]}`;
942
1181
  }
943
1182
  }
944
- if (integerFieldsArray.length > 0) {
945
- for (const fieldName of integerFieldsArray) {
946
- if (fieldsToUpdate[idx].hasOwnProperty(fieldName)) {
947
- fieldsToUpdate[idx][fieldName] = parseInt(fieldsToUpdate[idx][fieldName], 10);
948
- }
1183
+ }
1184
+ if (integerFieldsArray.length > 0) {
1185
+ for (const fieldName of integerFieldsArray) {
1186
+ if (recordFields.hasOwnProperty(fieldName)) {
1187
+ recordFields[fieldName] = parseInt(recordFields[fieldName], 10);
949
1188
  }
950
1189
  }
951
- if (floatFieldsArray.length > 0) {
952
- for (const fieldName of floatFieldsArray) {
953
- if (fieldsToUpdate[idx].hasOwnProperty(fieldName)) {
954
- fieldsToUpdate[idx][fieldName] = parseFloat(fieldsToUpdate[idx][fieldName]);
955
- }
1190
+ }
1191
+ if (floatFieldsArray.length > 0) {
1192
+ for (const fieldName of floatFieldsArray) {
1193
+ if (recordFields.hasOwnProperty(fieldName)) {
1194
+ recordFields[fieldName] = parseFloat(recordFields[fieldName]);
956
1195
  }
957
1196
  }
958
-
959
- const newRecord = {
960
- ...oldRecord,
961
- ...fieldsToUpdate[idx]
962
- };
963
- return this.adminforth.updateResourceRecord({
964
- resource: this.resourceConfig,
965
- recordId: ID,
966
- oldRecord: oldRecord,
967
- updates: newRecord,
968
- adminUser: adminUser,
969
- })
970
- });
971
- try {
972
- await Promise.all(updates);
973
- } catch (error) {
974
- return { ok: false, error: `Error updating records, because of unprocesseble data for record ID ${selectedIds}` };
975
1197
  }
976
- return { ok: true };
977
- } else {
978
- return { ok: false, error: isAllowedToSave.error };
1198
+
1199
+ const newRecord = {
1200
+ ...oldRecord,
1201
+ ...recordFields
1202
+ };
1203
+ return this.adminforth.updateResourceRecord({
1204
+ resource: this.resourceConfig,
1205
+ recordId: ID,
1206
+ oldRecord: oldRecord,
1207
+ updates: newRecord,
1208
+ adminUser: adminUser,
1209
+ })
1210
+ });
1211
+ try {
1212
+ await Promise.all(updates);
1213
+ } catch (error) {
1214
+ return { ok: false, error: `Error updating records, because of unprocesseble data for record ID ${selectedIds}` };
979
1215
  }
1216
+ return { ok: true };
980
1217
  }
981
1218
  });
982
1219
 
@@ -985,11 +1222,17 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
985
1222
  method: 'POST',
986
1223
  path: `/plugin/${this.pluginInstanceId}/get_image_generation_prompts`,
987
1224
  request_schema: getImageGenerationPromptsBodySchema,
988
- handler: async ({ body, headers, response }) => {
1225
+ handler: async ({ body, adminUser, headers, query, cookies, requestUrl }) => {
989
1226
  const data = body as z.infer<typeof getImageGenerationPromptsBodySchema>;
990
- const Id = data.recordId || [];
991
1227
  const customPrompt = data.customPrompt || null;
992
- const record = await this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(this.resourceConfig.columns.find(c => c.primaryKey)?.name, Id)]);
1228
+ const { record, error } = await this.getRecordForShow({
1229
+ adminUser,
1230
+ recordId: data.recordId,
1231
+ extra: { body, query, headers, cookies, requestUrl },
1232
+ });
1233
+ if (error) {
1234
+ return { ok: false, error };
1235
+ }
993
1236
  const compiledGenerationOptions = await this.compileGenerationFieldTemplates(record, JSON.stringify({"prompt": customPrompt}));
994
1237
  return compiledGenerationOptions;
995
1238
  }
@@ -1179,6 +1422,18 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
1179
1422
  handler: async ({ body, adminUser, headers, query, cookies, requestUrl, response }) => {
1180
1423
  const resource = this.resourceConfig;
1181
1424
 
1425
+ const { allowedActions } = await interpretResource(
1426
+ adminUser,
1427
+ resource,
1428
+ { requestBody: body },
1429
+ ActionCheckSource.ListRequest,
1430
+ this.adminforth,
1431
+ );
1432
+ const listAllowed = allowedActions[AllowedActionsEnum.list] as boolean | string | undefined;
1433
+ if (listAllowed !== true) {
1434
+ return { ok: false, error: typeof listAllowed === 'string' ? listAllowed : 'You are not allowed to list records in this resource' };
1435
+ }
1436
+
1182
1437
  for (const hook of resource.hooks?.list?.beforeDatasourceRequest || []) {
1183
1438
  const filterTools = filtersTools.get(body);
1184
1439
  body.filtersTools = filterTools;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/bulk-ai-flow",
3
- "version": "1.27.3",
3
+ "version": "1.27.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },