@formio/appserver 2.14.1 → 2.16.0

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/README.md CHANGED
@@ -70,6 +70,8 @@ This library uses environment variables to configure the configurations required
70
70
  | JWT_EXPIRE_TIME | no | The number of minutes to expire your JWT token. | 240 |
71
71
  | PORTAL_SECRET | no | Provides the ability to connect to the App Server via the Remote Environment Connection within your Developer Portal. | YOUR_SECRET_KEY |
72
72
  | PROJECT_CACHE | no | Disables the cache for project resources (such as Project template, Project Settings, and Access Settings). Default "true" | true |
73
+ | PROJECT_TTL | no | How often, in seconds, the project's forms, resources and actions are loaded again, which is how changes made in the portal or deployed to the project reach the App Server. `0` disables refreshing. Default 900 | 900 |
74
+ | TEMPLATE_SYNC | no | When the forms, resources and actions in a module's `template` that the project is missing are installed into it: `boot` once at startup, `refresh` also on every `PROJECT_TTL` refresh, or `off` never. Nothing the project already has is changed. The project remains the source of truth, so use `off` when forms are managed through stage deployments. Default "boot" | boot |
73
75
  | MONGO_CONFIG | no | Additional configurations to add to the DB connection string | {} |
74
76
 
75
77
  Enjoy
@@ -154,17 +154,23 @@ exports.LoginAction = {
154
154
  },
155
155
  executor(actionForm, action, handler, method) {
156
156
  return __awaiter(this, void 0, void 0, function* () {
157
- var _a, _b, _c, _d;
157
+ var _a, _b;
158
158
  let loginProvider = null;
159
- if (!action.settings.provider || action.settings.provider !== 'local') {
160
- loginProvider = new appserver_core_1.LocalAuthProvider(actionForm.project, (0, lodash_1.defaultsDeep)((_b = (_a = actionForm.project) === null || _a === void 0 ? void 0 : _a.config) === null || _b === void 0 ? void 0 : _b.auth, {
161
- local: action.settings
162
- }));
159
+ // Each login action gets its own copy of the auth config: the project's
160
+ // config is shared by every form, so mutating it would let the first
161
+ // login action's settings leak into all the others. The action's own
162
+ // settings take precedence; the project's fill in what it leaves out.
163
+ const settings = (0, lodash_1.cloneDeep)(action.settings || {});
164
+ const projectAuth = (0, lodash_1.cloneDeep)(((_b = (_a = actionForm.project) === null || _a === void 0 ? void 0 : _a.config) === null || _b === void 0 ? void 0 : _b.auth) || {});
165
+ const provider = settings.provider || 'local';
166
+ if (provider === 'local') {
167
+ // The settings form names the login fields `username` and `password`;
168
+ // the provider reads them as `userfield` and `passfield`.
169
+ const local = Object.assign(Object.assign({}, settings), { userfield: settings.userfield || settings.username, passfield: settings.passfield || settings.password });
170
+ loginProvider = new appserver_core_1.LocalAuthProvider(actionForm.project, (0, lodash_1.defaultsDeep)({ local }, projectAuth));
163
171
  }
164
- else if (action.settings.provider === 'oidc') {
165
- loginProvider = new appserver_core_1.OIDCProvider(actionForm.project, (0, lodash_1.defaultsDeep)((_d = (_c = actionForm.project) === null || _c === void 0 ? void 0 : _c.config) === null || _d === void 0 ? void 0 : _d.auth, {
166
- oidc: action.settings
167
- }));
172
+ else if (provider === 'oidc') {
173
+ loginProvider = new appserver_core_1.OIDCProvider(actionForm.project, (0, lodash_1.defaultsDeep)({ oidc: settings }, projectAuth));
168
174
  }
169
175
  if (loginProvider === null || loginProvider === void 0 ? void 0 : loginProvider.initialize) {
170
176
  yield loginProvider.initialize();
@@ -107,7 +107,7 @@ exports.RoleAction = {
107
107
  executor(form, action, handler, method) {
108
108
  return __awaiter(this, void 0, void 0, function* () {
109
109
  return (req, res, next) => __awaiter(this, void 0, void 0, function* () {
110
- var _a, _b, _c;
110
+ var _a, _b, _c, _d;
111
111
  debug('Role Action');
112
112
  // Check the submission for the submissionId.
113
113
  if (action.settings.association !== 'existing' && action.settings.association !== 'new') {
@@ -151,12 +151,18 @@ exports.RoleAction = {
151
151
  if (roleIndex === -1) {
152
152
  return next();
153
153
  }
154
- resource.roles.slice(roleIndex, 1);
154
+ resource.roles.splice(roleIndex, 1);
155
155
  break;
156
156
  }
157
157
  debug('Updating user with roles', resource.roles);
158
- // Update the user.
159
- yield form.saveSubmission(resource, ['roles'], req.auth);
158
+ // Update the user through the form the resource belongs to. After a
159
+ // Save action to another resource, the resource is that form's
160
+ // submission, and saving it through this form would match nothing.
161
+ const resourceFormId = resource.form ? String(resource.form) : '';
162
+ const target = (resourceFormId && resourceFormId !== String((_d = form.form) === null || _d === void 0 ? void 0 : _d._id))
163
+ ? (yield form.project.getFormById(resourceFormId)) || form
164
+ : form;
165
+ yield target.saveSubmission(resource, ['roles'], req.auth);
160
166
  next();
161
167
  });
162
168
  });
@@ -41,7 +41,7 @@ exports.SaveAction = {
41
41
  },
42
42
  mappingComponents(form) {
43
43
  return __awaiter(this, void 0, void 0, function* () {
44
- var _a;
44
+ var _a, _b;
45
45
  const components = [
46
46
  {
47
47
  type: 'select',
@@ -89,8 +89,11 @@ exports.SaveAction = {
89
89
  customConditional: `show = row.mapType === 'simple' && row.resource === "${key}"`,
90
90
  components: []
91
91
  };
92
+ // Each resource field is mapped from a field of this form, so the
93
+ // options are this form's fields (childSubmission reads them from
94
+ // the submitted data by these keys).
92
95
  const toFields = [];
93
- appserver_core_1.Utils.eachComponent(resource.components, (component) => {
96
+ appserver_core_1.Utils.eachComponent(((_b = form.form) === null || _b === void 0 ? void 0 : _b.components) || [], (component) => {
94
97
  if (['button'].indexOf(component.type) !== -1) {
95
98
  return;
96
99
  }
@@ -220,6 +223,14 @@ exports.SaveAction = {
220
223
  catch (err) {
221
224
  console.log(`Error in mapping transform: ${err.message}`);
222
225
  }
226
+ // A script that fails, or never assigns `body`, yields nothing usable.
227
+ if (!childSub || typeof childSub !== 'object') {
228
+ error('The advanced mapping did not produce a body; saving an empty resource submission.');
229
+ childSub = { data: {} };
230
+ }
231
+ if (!childSub.data || typeof childSub.data !== 'object') {
232
+ childSub.data = {};
233
+ }
223
234
  }
224
235
  else {
225
236
  (0, each_1.default)(action.settings.fields, function (field, key) {
@@ -234,7 +245,13 @@ exports.SaveAction = {
234
245
  childSub.metadata = submission.metadata;
235
246
  if (action.settings.transform) {
236
247
  try {
237
- childSub.data = appserver_core_1.Utils.evaluate(action.settings.transform, appserver_core_1.Utils.evalContext(form, req.submission || req.body, req.auth, req.headers, req.findQuery), 'data');
248
+ // `submission` is the original request; `data` is the data
249
+ // already produced by the mappings above.
250
+ const transformed = appserver_core_1.Utils.evaluate(action.settings.transform, Object.assign(Object.assign({}, appserver_core_1.Utils.evalContext(form, req.submission || req.body, req.auth, req.headers, req.findQuery)), { data: childSub.data }), 'data');
251
+ // A failed transform leaves the mapped data as it was.
252
+ if (transformed && typeof transformed === 'object') {
253
+ childSub.data = transformed;
254
+ }
238
255
  }
239
256
  catch (err) {
240
257
  console.log(`Error in submission transform: ${err.message}`);
@@ -285,28 +302,32 @@ exports.SaveAction = {
285
302
  if ((_a = action.settings) === null || _a === void 0 ? void 0 : _a.resource) {
286
303
  debug('Saving to resource: ' + action.settings.resource);
287
304
  const saveTo = yield form.project.getForm(action.settings.resource);
288
- if (saveTo) {
289
- if (!saveTo.handlers) {
290
- error('Cannot find resource handlers.');
291
- return next('Cannot find resource handlers.');
292
- }
293
- const handlers = req.method === 'PUT' ? saveTo.handlers.update : saveTo.handlers.create;
294
- const childReq = appserver_core_1.Utils.childRequest(req);
295
- if (!childReq) {
296
- error('Failed to create child request');
297
- return next('Failed to create child request');
298
- }
299
- childReq.body = exports.SaveAction.childSubmission(action, req, res, saveTo, childReq.body);
300
- return async_1.default.series(handlers.map((handler) => async_1.default.apply(handler, childReq, res)), (err) => {
301
- if (err) {
302
- error(err);
303
- return next(err);
304
- }
305
- // Set the child response based on the response of the subform.
306
- exports.SaveAction.childResponse(action, req, res, saveTo, childReq.body);
307
- next();
308
- });
305
+ if (!saveTo) {
306
+ // Never fall back to saving into this form: the data was
307
+ // meant for another resource.
308
+ error(`Resource "${action.settings.resource}" not found for the save action.`);
309
+ return next(`Resource "${action.settings.resource}" not found.`);
310
+ }
311
+ if (!saveTo.handlers) {
312
+ error('Cannot find resource handlers.');
313
+ return next('Cannot find resource handlers.');
314
+ }
315
+ const handlers = req.method === 'PUT' ? saveTo.handlers.update : saveTo.handlers.create;
316
+ const childReq = appserver_core_1.Utils.childRequest(req);
317
+ if (!childReq) {
318
+ error('Failed to create child request');
319
+ return next('Failed to create child request');
309
320
  }
321
+ childReq.body = exports.SaveAction.childSubmission(action, req, res, saveTo, childReq.body);
322
+ return async_1.default.series(handlers.map((handler) => async_1.default.apply(handler, childReq, res)), (err) => {
323
+ if (err) {
324
+ error(err);
325
+ return next(err);
326
+ }
327
+ // Set the child response based on the response of the subform.
328
+ exports.SaveAction.childResponse(action, req, res, saveTo, childReq.body);
329
+ next();
330
+ });
310
331
  }
311
332
  // Save the submission.
312
333
  req.saveSubmission = true;
@@ -1,12 +1,20 @@
1
1
  import { Collection, Document, Db, MongoClient, FindOneOptions } from 'mongodb';
2
2
  import { Component, Submission } from '@formio/core';
3
3
  import { ServerDB, DBConfig, AppServerForm, DatabaseIndexResponse, IndexQuery } from '@formio/appserver-core';
4
+ /**
5
+ * Whether a URL filter names a field. Mongo operators (`$where`, `$expr`, ...)
6
+ * and prototype keys are never fields, and would let a caller run JavaScript in
7
+ * the database or change the query's structure.
8
+ */
9
+ export declare function isFilterField(name: string): boolean;
4
10
  export declare class Database implements ServerDB {
5
11
  config: DBConfig;
6
12
  db: Db | null;
7
13
  client: MongoClient | null;
8
14
  currentCollectionName: string;
9
15
  currentCollection: Collection<Document> | null;
16
+ /** Collections for forms with `settings.collection`, created and indexed once each. */
17
+ formCollections: Map<string, Collection<Document>>;
10
18
  defaultCollection: Collection<Document> | null;
11
19
  prefix: string;
12
20
  constructor(config: DBConfig);
@@ -88,7 +96,13 @@ export declare class Database implements ServerDB {
88
96
  * @param {*} id
89
97
  * @returns
90
98
  */
91
- query(form: AppServerForm, query?: any, includeAll?: boolean): any;
99
+ /**
100
+ * Scope a query to the form and project. Including deleted records drops only
101
+ * the `deleted` filter, never the form or project scope: every form writes to
102
+ * the shared submissions collection. `scope = false` is for nested
103
+ * `$or`/`$and` clauses, which only need their ids converted.
104
+ */
105
+ query(form: AppServerForm, query?: any, includeDeleted?: boolean, scope?: boolean): any;
92
106
  /**
93
107
  * Find many records that match a query.
94
108
  */
@@ -21,14 +21,30 @@ var __rest = (this && this.__rest) || function (s, e) {
21
21
  };
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
23
  exports.Database = void 0;
24
+ exports.isFilterField = isFilterField;
24
25
  const mongodb_1 = require("mongodb");
25
26
  const lodash_1 = require("lodash");
26
27
  const core_1 = require("@formio/core");
27
28
  const debug = require('debug')('formio:db');
28
29
  const error = require('debug')('formio:error');
30
+ /** The operators a URL filter may use (`data.age__gt=3`). */
31
+ const FILTER_SELECTORS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'exists', 'regex'];
32
+ /** The default and largest number of records an index request returns. */
33
+ const DEFAULT_LIMIT = 10;
34
+ const MAX_LIMIT = 1000;
35
+ /**
36
+ * Whether a URL filter names a field. Mongo operators (`$where`, `$expr`, ...)
37
+ * and prototype keys are never fields, and would let a caller run JavaScript in
38
+ * the database or change the query's structure.
39
+ */
40
+ function isFilterField(name) {
41
+ return !!name && name.split('.').every((segment) => !!segment && !segment.startsWith('$') && !['__proto__', 'constructor', 'prototype'].includes(segment));
42
+ }
29
43
  class Database {
30
44
  constructor(config) {
31
45
  this.config = config;
46
+ /** Collections for forms with `settings.collection`, created and indexed once each. */
47
+ this.formCollections = new Map();
32
48
  this.prefix = '';
33
49
  this.db = null;
34
50
  this.client = null; // Add this line
@@ -37,8 +53,18 @@ class Database {
37
53
  this.defaultCollection = null;
38
54
  }
39
55
  ObjectId(id) {
40
- if (id === null || id === void 0 ? void 0 : id.hasOwnProperty('$in')) {
41
- return { $in: id['$in'].map((_id) => this.ObjectId(_id)) };
56
+ // Operator objects from URL filters (`_id__in`, `owner__ne`, ...) convert
57
+ // their operand(s), so the comparison is against stored ObjectIds.
58
+ // Every operator is converted: dropping one (e.g. the `$eq` of
59
+ // `owner=x&owner__ne=y`) would widen the query.
60
+ if ((0, lodash_1.isPlainObject)(id) && Object.keys(id).some((key) => key.startsWith('$'))) {
61
+ const converted = {};
62
+ for (const [op, value] of Object.entries(id)) {
63
+ converted[op] = ['$in', '$nin'].includes(op) && Array.isArray(value)
64
+ ? value.map((_id) => this.ObjectId(_id))
65
+ : (['$eq', '$ne'].includes(op) ? this.ObjectId(value) : value);
66
+ }
67
+ return converted;
42
68
  }
43
69
  try {
44
70
  return id ? (new mongodb_1.ObjectId(id)) : null;
@@ -69,14 +95,15 @@ class Database {
69
95
  if (this.config.dropOnConnect) {
70
96
  yield this.db.dropDatabase();
71
97
  }
72
- this.addIndex(this.collection('project'), 'name');
98
+ yield this.addIndex(this.collection('project'), 'name');
73
99
  this.defaultCollection = this.collection('submissions');
74
100
  yield this.setupIndexes(this.defaultCollection);
75
101
  debug('Connected to database');
76
102
  }
77
103
  catch (err) {
78
104
  error(err.message);
79
- process.exit();
105
+ // A non-zero code, so supervisors treat a failed connection as a failure.
106
+ process.exit(1);
80
107
  }
81
108
  });
82
109
  }
@@ -219,24 +246,38 @@ class Database {
219
246
  Object.entries(filters).forEach(([name, value]) => {
220
247
  // Get the filter object.
221
248
  const filter = (0, lodash_1.zipObject)(['name', 'selector'], name.split('__'));
249
+ // Only field filters with a known operator reach the database.
250
+ if (!isFilterField(filter.name) || (filter.selector && !FILTER_SELECTORS.includes(filter.selector))) {
251
+ debug(`Ignoring query filter "${name}"`);
252
+ return;
253
+ }
222
254
  if (filter.selector) {
223
255
  var filterQuery = dbQuery[filter.name];
224
- // Init the filter.
225
- if (!filterQuery) {
256
+ // Init the filter. A plain filter on the same field (`?a=5&a__gt=3`)
257
+ // is kept as `$eq` alongside the operator.
258
+ if (filterQuery === undefined) {
226
259
  filterQuery = {};
227
260
  }
261
+ else if (!(0, lodash_1.isPlainObject)(filterQuery)) {
262
+ filterQuery = { $eq: filterQuery };
263
+ }
228
264
  if (filter.selector === 'exists') {
229
265
  value = ((value === 'true') || (value === '1')) ? true : value;
230
266
  value = ((value === 'false') || (value === '0')) ? false : value;
231
267
  value = !!value;
232
268
  }
233
269
  else if (filter.selector === 'regex') {
234
- const parts = value.match(/\/?([^/]+)\/?([^/]+)?/);
235
- if (!parts) {
270
+ // Either `/pattern/flags` or a bare pattern. The pattern is always
271
+ // matched literally: login builds these filters from user input,
272
+ // so unescaped patterns would allow regex injection.
273
+ const delimited = String(value).match(/^\/([\s\S]*)\/([a-z]*)$/);
274
+ const pattern = delimited ? delimited[1] : String(value);
275
+ const flags = delimited && /^[gimsuy]*$/.test(delimited[2]) && delimited[2] ? delimited[2] : 'i';
276
+ if (!pattern) {
236
277
  return;
237
278
  }
238
279
  try {
239
- value = new RegExp((0, lodash_1.escapeRegExp)(parts[1]), (parts[2] || 'i'));
280
+ value = new RegExp((0, lodash_1.escapeRegExp)(pattern), flags);
240
281
  }
241
282
  catch (err) {
242
283
  error(err);
@@ -257,9 +298,15 @@ class Database {
257
298
  return;
258
299
  }
259
300
  else {
260
- // Set the find query to this value.
301
+ // Set the find query to this value. When an operator filter on the
302
+ // same field came first, keep it and add this value as `$eq`.
261
303
  value = this.getQueryValue(filter.name, value, filter.selector);
262
- dbQuery[filter.name] = value;
304
+ if ((0, lodash_1.isPlainObject)(dbQuery[filter.name])) {
305
+ dbQuery[filter.name].$eq = value;
306
+ }
307
+ else {
308
+ dbQuery[filter.name] = value;
309
+ }
263
310
  return;
264
311
  }
265
312
  });
@@ -294,22 +341,31 @@ class Database {
294
341
  formCollection(form) {
295
342
  return __awaiter(this, void 0, void 0, function* () {
296
343
  if (this.db && form && form.settings && form.settings.collection) {
297
- if (this.currentCollection && this.currentCollectionName === form.settings.collection) {
298
- return this.currentCollection;
344
+ const name = form.settings.collection;
345
+ const cached = this.formCollections.get(name);
346
+ if (cached) {
347
+ this.currentCollection = cached;
348
+ this.currentCollectionName = name;
349
+ return cached;
299
350
  }
300
- let collection = null;
301
351
  try {
302
- debug('db.createCollection()', form.settings.collection);
303
- yield this.db.createCollection(this.collectionName(form.settings.collection));
304
- collection = this.collection(form.settings.collection);
305
- this.setupIndexes(collection, form);
352
+ debug('db.createCollection()', name);
353
+ yield this.db.createCollection(this.collectionName(name));
306
354
  }
307
355
  catch (err) {
308
- error(err.message);
356
+ // 48 = NamespaceExists: the collection is already there, which is fine.
357
+ if ((err === null || err === void 0 ? void 0 : err.code) !== 48) {
358
+ error(err.message);
359
+ }
360
+ }
361
+ const collection = this.collection(name);
362
+ if (collection) {
363
+ yield this.setupIndexes(collection, form);
364
+ this.formCollections.set(name, collection);
309
365
  }
310
- this.currentCollection = collection || this.collection(form.settings.collection);
311
- this.currentCollectionName = form.settings.collection;
312
- return this.currentCollection;
366
+ this.currentCollection = collection;
367
+ this.currentCollectionName = name;
368
+ return collection;
313
369
  }
314
370
  return this.defaultCollection;
315
371
  });
@@ -322,14 +378,14 @@ class Database {
322
378
  if (!collection) {
323
379
  return;
324
380
  }
325
- this.addIndex(collection, 'project');
326
- this.addIndex(collection, 'form');
327
- this.addIndex(collection, 'deleted');
328
- this.addIndex(collection, 'modified');
381
+ yield this.addIndex(collection, 'project');
382
+ yield this.addIndex(collection, 'form');
383
+ yield this.addIndex(collection, 'deleted');
384
+ yield this.addIndex(collection, 'modified');
329
385
  // If TTL index is needed add that here.
330
386
  if (this.config.ttl) {
331
387
  try {
332
- collection.createIndex({
388
+ yield collection.createIndex({
333
389
  created: 1
334
390
  }, {
335
391
  background: true,
@@ -341,7 +397,7 @@ class Database {
341
397
  }
342
398
  }
343
399
  else {
344
- this.addIndex(collection, 'created');
400
+ yield this.addIndex(collection, 'created');
345
401
  }
346
402
  if (form && form.components) {
347
403
  core_1.Utils.eachComponent(form.components, (component, path) => {
@@ -364,7 +420,7 @@ class Database {
364
420
  index[path] = 1;
365
421
  try {
366
422
  debug('db.addIndex()', path);
367
- collection.createIndex(index, { background: true });
423
+ yield collection.createIndex(index, { background: true });
368
424
  }
369
425
  catch (err) {
370
426
  error(`Cannot add index ${path}`, err.message);
@@ -425,9 +481,10 @@ class Database {
425
481
  * @returns
426
482
  */
427
483
  indexQuery(urlQuery) {
428
- let skip = 0, limit = 10;
429
- limit = parseInt(urlQuery.limit) || 10;
430
- skip = parseInt(urlQuery.skip) || 0;
484
+ // Bounded, so one request can't read (or make the server hold) everything.
485
+ const maxLimit = Number(this.config.maxLimit) || MAX_LIMIT;
486
+ const limit = Math.min(Math.max(parseInt(urlQuery.limit) || DEFAULT_LIMIT, 1), maxLimit);
487
+ const skip = Math.max(parseInt(urlQuery.skip) || 0, 0);
431
488
  let sort = { created: -1 };
432
489
  if (urlQuery.sort) {
433
490
  if (typeof urlQuery.sort === 'string') {
@@ -443,7 +500,8 @@ class Database {
443
500
  sort = urlQuery.sort;
444
501
  }
445
502
  }
446
- const includeDeleted = urlQuery.includeDeleted || false;
503
+ // Only an explicit "true" includes deleted records ("false" is truthy).
504
+ const includeDeleted = urlQuery.includeDeleted === true || urlQuery.includeDeleted === 'true';
447
505
  let project = {};
448
506
  if (urlQuery.select) {
449
507
  if (typeof urlQuery.select === 'string') {
@@ -525,7 +583,7 @@ class Database {
525
583
  if (form && form._id) {
526
584
  record.form = this.ObjectId(form._id);
527
585
  }
528
- if (form.project && form.project) {
586
+ if (form === null || form === void 0 ? void 0 : form.project) {
529
587
  record.project = this.ObjectId(form.project);
530
588
  }
531
589
  if (record.owner) {
@@ -563,29 +621,37 @@ class Database {
563
621
  * @param {*} id
564
622
  * @returns
565
623
  */
566
- query(form, query = {}, includeAll = false) {
624
+ /**
625
+ * Scope a query to the form and project. Including deleted records drops only
626
+ * the `deleted` filter, never the form or project scope: every form writes to
627
+ * the shared submissions collection. `scope = false` is for nested
628
+ * `$or`/`$and` clauses, which only need their ids converted.
629
+ */
630
+ query(form, query = {}, includeDeleted = false, scope = true) {
567
631
  if (query._id) {
568
632
  query._id = this.ObjectId(query._id);
569
633
  }
570
634
  if (query.owner) {
571
635
  query.owner = this.ObjectId(query.owner);
572
636
  }
573
- if (query.form) {
574
- query.form = this.ObjectId(query.form);
575
- }
576
- else if (!includeAll && form && form._id) {
637
+ // The form scope always wins over a form filter in the query, exactly as
638
+ // the project scope does: every form shares the submissions collection.
639
+ if (scope && form && form._id) {
577
640
  query.form = this.ObjectId(form._id);
578
641
  }
579
- if (!includeAll && form.project) {
642
+ else if (query.form) {
643
+ query.form = this.ObjectId(query.form);
644
+ }
645
+ if (scope && (form === null || form === void 0 ? void 0 : form.project)) {
580
646
  query.project = this.ObjectId(form.project);
581
647
  }
582
- if (!includeAll) {
648
+ if (scope && !includeDeleted) {
583
649
  query.deleted = { $eq: null };
584
650
  }
585
651
  // Handle nested queries.
586
652
  ['$or', '$and'].forEach((key) => {
587
653
  if (query[key]) {
588
- query[key].forEach((subQuery) => this.query(form, subQuery, true));
654
+ query[key].forEach((subQuery) => this.query(form, subQuery, true, false));
589
655
  }
590
656
  });
591
657
  return query;
@@ -809,16 +875,23 @@ class Database {
809
875
  else if ((0, lodash_1.isArray)(value)) {
810
876
  this.addPathQueryParams({ $all: value }, query, path);
811
877
  }
812
- else if ((0, lodash_1.isObject)(value) || (0, lodash_1.isNumber)(value)) {
878
+ else {
879
+ // Numbers, objects, booleans: match the exact value. Without a value
880
+ // predicate the query would match any submitted record.
813
881
  this.addPathQueryParams({ $eq: value }, query, path);
814
882
  }
815
883
  // Only search for non-deleted items
816
884
  query.deleted = { $eq: null };
817
885
  query.state = 'submitted';
818
- // Perform the query.
886
+ // Perform the query. Errors must reach the catch below (findOne() swallows
887
+ // them), or a failed lookup would be reported as unique.
888
+ const lookup = (dbQuery, findOptions) => __awaiter(this, void 0, void 0, function* () {
889
+ const collection = yield this.formCollection(form);
890
+ return collection ? yield collection.findOne(this.query(form, dbQuery), findOptions) : null;
891
+ });
819
892
  let result = null;
820
893
  try {
821
- result = yield this.findOne(form, query, options);
894
+ result = yield lookup(query, options);
822
895
  if (!(result === null || result === void 0 ? void 0 : result._id) || ((result === null || result === void 0 ? void 0 : result._id.toString()) === (submission === null || submission === void 0 ? void 0 : submission._id))) {
823
896
  return true;
824
897
  }
@@ -835,13 +908,14 @@ class Database {
835
908
  $options: 'i'
836
909
  }, query, path);
837
910
  try {
838
- result = yield this.findOne(form, query);
911
+ result = yield lookup(query);
839
912
  if (!(result === null || result === void 0 ? void 0 : result._id) || ((result === null || result === void 0 ? void 0 : result._id.toString()) === (submission === null || submission === void 0 ? void 0 : submission._id))) {
840
913
  return true;
841
914
  }
842
915
  else {
843
- component.conflictId = result._id.toString();
844
- return component.conflictId;
916
+ const conflictId = result._id.toString();
917
+ component.conflictId = conflictId;
918
+ return conflictId;
845
919
  }
846
920
  }
847
921
  catch (err) {
package/lib/server.js CHANGED
@@ -24,7 +24,6 @@ const appserver_core_1 = require("@formio/appserver-core");
24
24
  const modules_1 = require("./modules");
25
25
  const actions_1 = __importDefault(require("./actions"));
26
26
  const defaultsDeep_1 = __importDefault(require("lodash/defaultsDeep"));
27
- ``;
28
27
  const packageJson = require('../package.json');
29
28
  const appCorePackage = require('@formio/appserver-core/package.json');
30
29
  __exportStar(require("@formio/appserver-core"), exports);
@@ -57,6 +56,7 @@ class Server extends appserver_core_1.Server {
57
56
  },
58
57
  project: (0, lodash_1.get)(process.env, 'PROJECT', ''),
59
58
  ttl: parseInt((0, lodash_1.get)(process.env, 'PROJECT_TTL', 900).toString(), 10),
59
+ templateSync: (0, lodash_1.get)(process.env, 'TEMPLATE_SYNC', 'boot').toString().toLowerCase(),
60
60
  submissionProxy: (0, lodash_1.get)(process.env, 'SUBMISSION_PROXY', '').toString(),
61
61
  projects: (0, lodash_1.get)(process.env, 'PROJECTS', '').split(',').filter((p) => !!p),
62
62
  maxBodySize: (0, lodash_1.get)(process.env, 'MAX_BODY_SIZE', '100mb')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formio/appserver",
3
- "version": "2.14.1",
3
+ "version": "2.16.0",
4
4
  "description": "A highly performant and extensible Application Server for Form.io Multi-Tenant Deployments.",
5
5
  "main": "lib/server.js",
6
6
  "types": "lib/server.d.ts",
@@ -13,7 +13,8 @@
13
13
  "build:lib": "npm run clean && tsc; true",
14
14
  "test": "mocha",
15
15
  "dev": "tsc --watch --preserveWatchOutput",
16
- "prepack": "npm run build"
16
+ "prepack": "npm run build",
17
+ "test:coverage": "c8 --all --reporter=text-summary --reporter=html --include \"src/**/*.ts\" --exclude \"src/**/*.spec.ts\" mocha"
17
18
  },
18
19
  "repository": {
19
20
  "type": "git",
@@ -33,33 +34,34 @@
33
34
  ],
34
35
  "homepage": "https://github.com/formio/appserver#readme",
35
36
  "dependencies": {
36
- "@formio/appserver-core": "^2.14.1",
37
- "@formio/core": "2.8.0",
37
+ "@formio/appserver-core": "^2.16.0",
38
+ "@formio/core": "2.9.0",
38
39
  "async": "^3.2.6",
39
40
  "bcryptjs": "^3.0.3",
40
41
  "debug": "^4.4.3",
41
- "dotenv": "^17.4.2",
42
- "express": "^5.2.1",
42
+ "dotenv": "^18.0.8",
43
+ "express": "^5.3.0",
43
44
  "jsonwebtoken": "^9.0.3",
44
45
  "lodash": "^4.18.1",
45
- "mongodb": "^7.5.0"
46
+ "mongodb": "^7.7.0"
46
47
  },
47
48
  "devDependencies": {
48
- "@types/async": "^3.2.25",
49
+ "@types/async": "^3.2.26",
49
50
  "@types/bcryptjs": "^3.0.0",
50
51
  "@types/chai": "^5.2.3",
51
52
  "@types/express": "^5.0.6",
52
53
  "@types/jsonwebtoken": "^9.0.10",
53
- "@types/lodash": "^4.17.24",
54
+ "@types/lodash": "^4.17.26",
54
55
  "@types/mocha": "^10.0.10",
55
- "@types/node": "^26.1.2",
56
- "chai": "^6.2.2",
56
+ "@types/node": "^26.6.5",
57
+ "c8": "^12.0.0",
58
+ "chai": "^6.3.0",
57
59
  "cross-env": "^10.1.0",
58
- "mocha": "^11.7.6",
59
- "mongodb-memory-server": "^11.2.0",
60
+ "mocha": "^12.0.3",
61
+ "mongodb-memory-server": "^11.3.0",
60
62
  "ts-node": "^10.9.2",
61
63
  "tsconfig-paths": "^4.2.0",
62
- "tsx": "^4.23.1",
64
+ "tsx": "^4.23.15",
63
65
  "typescript": "^7.0.2"
64
66
  }
65
67
  }