apostrophe 3.53.0 → 3.55.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +58 -1
  2. package/defaults.js +1 -0
  3. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextModeAndSettings.vue +5 -2
  4. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +28 -19
  5. package/modules/@apostrophecms/any-doc-type/index.js +2 -2
  6. package/modules/@apostrophecms/any-page-type/index.js +2 -2
  7. package/modules/@apostrophecms/doc/index.js +55 -29
  8. package/modules/@apostrophecms/doc-type/index.js +11 -6
  9. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocContextMenu.vue +4 -440
  10. package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +445 -0
  11. package/modules/@apostrophecms/i18n/i18n/de.json +113 -105
  12. package/modules/@apostrophecms/i18n/i18n/es.json +10 -0
  13. package/modules/@apostrophecms/i18n/i18n/fr.json +8 -0
  14. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +10 -0
  15. package/modules/@apostrophecms/i18n/i18n/sk.json +8 -0
  16. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +1 -0
  17. package/modules/@apostrophecms/log/index.js +429 -0
  18. package/modules/@apostrophecms/login/index.js +47 -4
  19. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +14 -1
  20. package/modules/@apostrophecms/modal/ui/apos/mixins/AposEditorMixin.js +1 -1
  21. package/modules/@apostrophecms/module/index.js +32 -6
  22. package/modules/@apostrophecms/module/lib/log.js +68 -0
  23. package/modules/@apostrophecms/page/index.js +71 -19
  24. package/modules/@apostrophecms/page/lib/legacy-migrations.js +0 -57
  25. package/modules/@apostrophecms/page/ui/apos/components/AposPagesManager.vue +8 -285
  26. package/modules/@apostrophecms/page/ui/apos/logic/AposPagesManager.js +291 -0
  27. package/modules/@apostrophecms/page-type/index.js +39 -26
  28. package/modules/@apostrophecms/piece-type/index.js +19 -11
  29. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +1 -0
  30. package/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue +2 -357
  31. package/modules/@apostrophecms/schema/ui/apos/components/AposInputArea.vue +2 -86
  32. package/modules/@apostrophecms/schema/ui/apos/components/AposInputArray.vue +2 -254
  33. package/modules/@apostrophecms/schema/ui/apos/components/AposInputAttachment.vue +2 -77
  34. package/modules/@apostrophecms/schema/ui/apos/components/AposInputBoolean.vue +2 -44
  35. package/modules/@apostrophecms/schema/ui/apos/components/AposInputCheckboxes.vue +2 -64
  36. package/modules/@apostrophecms/schema/ui/apos/components/AposInputColor.vue +2 -94
  37. package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +3 -47
  38. package/modules/@apostrophecms/schema/ui/apos/components/AposInputObject.vue +2 -82
  39. package/modules/@apostrophecms/schema/ui/apos/components/AposInputPassword.vue +2 -37
  40. package/modules/@apostrophecms/schema/ui/apos/components/AposInputRadio.vue +2 -26
  41. package/modules/@apostrophecms/schema/ui/apos/components/AposInputRange.vue +2 -57
  42. package/modules/@apostrophecms/schema/ui/apos/components/AposInputRelationship.vue +2 -259
  43. package/modules/@apostrophecms/schema/ui/apos/components/AposInputSelect.vue +2 -38
  44. package/modules/@apostrophecms/schema/ui/apos/components/AposInputSlug.vue +2 -275
  45. package/modules/@apostrophecms/schema/ui/apos/components/AposInputString.vue +2 -167
  46. package/modules/@apostrophecms/schema/ui/apos/components/AposInputWrapper.vue +2 -115
  47. package/modules/@apostrophecms/schema/ui/apos/components/AposSchema.vue +3 -279
  48. package/modules/@apostrophecms/schema/ui/apos/components/AposSearchList.vue +2 -83
  49. package/modules/@apostrophecms/schema/ui/apos/lib/detectChange.js +10 -1
  50. package/modules/@apostrophecms/schema/ui/apos/logic/AposArrayEditor.js +361 -0
  51. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputArea.js +89 -0
  52. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputArray.js +257 -0
  53. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputAttachment.js +81 -0
  54. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputBoolean.js +48 -0
  55. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js +68 -0
  56. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputColor.js +98 -0
  57. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputDateAndTime.js +49 -0
  58. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputObject.js +86 -0
  59. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputPassword.js +41 -0
  60. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js +29 -0
  61. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRange.js +60 -0
  62. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js +262 -0
  63. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputSelect.js +41 -0
  64. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputSlug.js +278 -0
  65. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputString.js +170 -0
  66. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js +118 -0
  67. package/modules/@apostrophecms/schema/ui/apos/logic/AposSchema.js +281 -0
  68. package/modules/@apostrophecms/schema/ui/apos/logic/AposSearchList.js +85 -0
  69. package/modules/@apostrophecms/template/index.js +1 -1
  70. package/modules/@apostrophecms/ui/ui/apos/components/AposTreeHeader.vue +2 -2
  71. package/modules/@apostrophecms/util/index.js +83 -13
  72. package/modules/@apostrophecms/util/lib/logger.js +19 -17
  73. package/package.json +1 -1
  74. package/test/docs.js +35 -2
  75. package/test/log.js +1765 -0
  76. package/test/pages.js +57 -0
  77. package/test-lib/util.js +1 -1
@@ -57,6 +57,7 @@
57
57
  :disable="relationshipErrors === 'min'"
58
58
  :displayed-items="items.length"
59
59
  :checked-count="checked.length"
60
+ :module-name="moduleName"
60
61
  @page-change="updatePage"
61
62
  @select-click="selectClick"
62
63
  @search="search"
@@ -0,0 +1,429 @@
1
+ // Structured logging for Apostrophe.
2
+ //
3
+ // This module is generic, low level implementation. For logging inside of a module,
4
+ // see `logInfo`, `logError`, etc. methods available in every module
5
+ // via the base class, @apostrophecms/module.
6
+ //
7
+ // ### `logger`
8
+ //
9
+ // Optional. It can be an object or a function.
10
+ // If a function it accepts `apos` and returns an object with
11
+ // at least `info`, `debug`, `warn` and `error` methods. If a `destroy` method
12
+ // is present it will be invoked and awaited (Promise) when Apostrophe is shut down.
13
+ // The object, or the returned object, must have `info`, `debug`, `warn` and `error` methods.
14
+ // If `destroy` is present it will be invoked and awaited (Promise) when Apostrophe is shut down.
15
+ // If this option is not supplied, logs are simply written to the Node.js `console`.
16
+ // Calls to `apos.utils.info`, `apos.utils.error`, etc. or module level `self.logInfo`,
17
+ // `self.logError`, etc are routed through this object by Apostrophe.
18
+ // This provides compatibility out of the box with many popular
19
+ // logging modules, including`pino`, `winston`, etc.
20
+ //
21
+ // ## Options
22
+ //
23
+ // ### `messageAs`
24
+ //
25
+ // When the messageAs option is set, the message argument to apos.util.info, etc.
26
+ // is bundled into the second, object - based argument as a property of the name
27
+ // given, and only the object argument is passed to the `logger`, which is useful
28
+ // if using Pino.
29
+ // If there is no object-based argument an object is created.
30
+ // Example:
31
+ // ```js
32
+ // {
33
+ // options: {
34
+ // messageAs: 'msg'
35
+ // }
36
+ // }
37
+ // ```
38
+ // The resulting util log call will be:
39
+ // ```js
40
+ // self.apos.util.error({
41
+ // msg: '@apostrophecms/login: incorrect-username: User admin failed to log in',
42
+ // type: 'incorrect-username'
43
+ // module: '@apostrophecms/login'
44
+ // });
45
+ // ```
46
+ //
47
+ // ### `filter`
48
+ //
49
+ // By module name, or `*` we can specify any mix of severity levels and
50
+ // specific event types, and entries are kept if *either* criterion is met.
51
+ // Example:
52
+ // ```js
53
+ // {
54
+ // options: {
55
+ // filter: {
56
+ // // Log all errors and warnings from any module
57
+ // '*': {
58
+ // severity: [ 'warn', 'error' ]
59
+ // // match all severity levels
60
+ // // severity: '*'
61
+ // // match event types
62
+ // // events: [ 'event-type-1', 'event-type-2' ]
63
+ // // match all event types
64
+ // // events: '*'
65
+ // },
66
+ // // Log specific event types from the login module
67
+ // '@apostrophecms/login': {
68
+ // events: [ 'incorrect-username', 'incorrect-password' ]
69
+ // // match all event types
70
+ // // events: '*'
71
+ // // match specific severity levels
72
+ // // severity: [ 'info' ]
73
+ // // match all severity levels
74
+ // // severity: '*'
75
+ // }
76
+ // }
77
+ // }
78
+ // }
79
+ // ```
80
+ // In this example, all errors and warnings from any module, but
81
+ // only the specific event types (no matter the severity) from the login
82
+ // module, are logged. The logs will be kept if *either* criterion is met.
83
+ // `filter['*'] = true` enables logging of all events from all modules.
84
+ //
85
+ // ## Environment Variables
86
+ //
87
+ // ### `APOS_FILTER_LOGS`
88
+ //
89
+ // If set, this environment variable overrides the `filter` option.
90
+ // Example:
91
+ // ```sh
92
+ // # same as the `filter` example above
93
+ // export APOS_FILTER_LOGS='*:severity:warn,error;@apostrophecms/login:events:incorrect-username,incorrect-password'
94
+ // # log everything, analogous to `{ filter: { '*': true }}`
95
+ // export APOS_FILTER_LOGS='*'
96
+ // ```
97
+ //
98
+
99
+ const _ = require('lodash');
100
+
101
+ module.exports = {
102
+ options: {
103
+ alias: 'structuredLog'
104
+ },
105
+ init(self) {
106
+ self.filters = {};
107
+ self.filterCache = {};
108
+ self.initFilters();
109
+ },
110
+ methods(self) {
111
+ // Keep those inside the methods because of performance.
112
+ // Do not move to the root because of tests.
113
+ const isProduction = process.env.NODE_ENV === 'production';
114
+ const isTest = self.options.apos?.options?.test;
115
+ const utilInspect = require('node:util').inspect;
116
+
117
+ const formatDevObj = isTest
118
+ ? (obj) => JSON.stringify(obj, null, 2)
119
+ : (obj) => utilInspect(obj, {
120
+ depth: null,
121
+ colors: true
122
+ });
123
+ const formatObj = isProduction
124
+ ? JSON.stringify
125
+ : formatDevObj;
126
+ const formatString = !isProduction
127
+ ? (str, args) => (args.length > 1) ? str.trim() + '\n' : str
128
+ : (str) => str;
129
+
130
+ return {
131
+ // Stringify object arguments. If `NODE_ENV` is not `production`,
132
+ // pretty print the objects and add a new line at the end of string arguments.
133
+ // This method is meant to be used from the methods of a custom `logger`.
134
+ // See the default logger implementation in `util/lib/logger.js` for an example.
135
+ formatLogByEnv(args) {
136
+ return args.map((arg) => {
137
+ if (typeof arg === 'string') {
138
+ return formatString(arg, args);
139
+ }
140
+ if (_.isPlainObject(arg)) {
141
+ return formatObj(arg);
142
+ }
143
+ return arg;
144
+ });
145
+ },
146
+ // Normalize the filters. Detect configuration and set defaults
147
+ // per the current NODE_ENV if needed.
148
+ // Convert `{ *: true }` to an object with all severity levels.
149
+ // Convert severity wildcards to arrays of (all) severity levels. This
150
+ // speeds up the severity detection (no wildcards match).
151
+ // Convert eventType wildcards to `[ '*' ]` array.
152
+ // Override the configuration with the `APOS_FILTER_LOGS` environment variable
153
+ // if set.
154
+ initFilters() {
155
+ self.filters = self.options.filter || {};
156
+ if (process.env.APOS_FILTER_LOGS) {
157
+ try {
158
+ self.filters = self.parseEnvFilter(process.env.APOS_FILTER_LOGS);
159
+ } catch (e) {
160
+ throw new Error(`Invalid APOS_FILTER_LOGS environment variable: ${e.message}`);
161
+ }
162
+ }
163
+ self.filters['*'] = self.filters['*'] || {};
164
+
165
+ // Transform *: true - log absolutely everything.
166
+ if (self.filters['*'] === true) {
167
+ self.filters['*'] = {
168
+ severity: self.getDefaultSeverity(false)
169
+ };
170
+ return;
171
+ }
172
+ // Add environment specific severity levels if no severity is specified.
173
+ if (!self.filters['*'].severity) {
174
+ self.filters['*'] = {
175
+ ...self.filters['*'],
176
+ severity: self.getDefaultSeverity(isProduction)
177
+ };
178
+ }
179
+
180
+ // Handle wildcards and validate.
181
+ for (const [ module, config ] of Object.entries(self.filters)) {
182
+ for (const [ type, value ] of Object.entries(config)) {
183
+ if (value === true || value === '*') {
184
+ config[type] = (type === 'severity')
185
+ ? self.getDefaultSeverity(false)
186
+ : [ '*' ];
187
+ }
188
+ if (!Array.isArray(config[type])) {
189
+ throw new Error(
190
+ `Invalid ${type} filter for module ${module}: ${JSON.stringify(config[type])}`
191
+ );
192
+ }
193
+ }
194
+ }
195
+ },
196
+ // Convert a string filter configuration to an object.
197
+ // Example:
198
+ // `*:severity:warn,error;@apostrophecms/login:events:success,failure`
199
+ // results in:
200
+ // {
201
+ // '*': {
202
+ // severity: [ 'warn', 'error' ]
203
+ // },
204
+ // '@apostrophecms/login': {
205
+ // events: [ 'success', 'failure' ]
206
+ // }
207
+ // }
208
+ // Log all is just `*` and results in `{ '*': true }`.
209
+ parseEnvFilter(envFilter) {
210
+ const filter = {};
211
+ if (envFilter === '*') {
212
+ filter['*'] = true;
213
+ return filter;
214
+ }
215
+ envFilter.split(';').forEach((entry) => {
216
+ const [ module, ...criteria ] = entry
217
+ .trim()
218
+ .split(':')
219
+ .map((str) => str.trim());
220
+ // Trnasform e.g. `events:ev1,ev2` to`{ events: [ 'ev1', 'ev2' ] }`.
221
+ filter[module] = criteria.reduce((acc, current, index, arr) => {
222
+ if (index % 2) {
223
+ return acc;
224
+ }
225
+ if (!arr[index + 1] || typeof arr[index + 1] !== 'string') {
226
+ throw new Error(`Malformed configuration for module "${module}".`);
227
+ }
228
+ acc[current] = arr[index + 1].split(',').map((str) => str.trim());
229
+ return acc;
230
+ }, {});
231
+ });
232
+ return filter;
233
+ },
234
+ getDefaultSeverity(forProd = false) {
235
+ return forProd
236
+ ? [ 'warn', 'error' ]
237
+ : [ 'debug', 'info', 'warn', 'error' ];
238
+ },
239
+
240
+ // Internal method, do not use it directly. See `@apostrophecms/module` for
241
+ // module level logging methods - logInfo, logError, etc.
242
+ // `moduleSelf` is the module `self` object.
243
+ // The allowed `severity` levels are `debug`, `info`, `warn` and `error`.
244
+ // `severity` is required.
245
+ // `req` (optional) is an apos request object.
246
+ // The implementor will suport the following signature:
247
+ // - (eventType)
248
+ // - (eventType, data)
249
+ // - (eventType, message)
250
+ // - (eventType, message, data)
251
+ // - (req, eventType[, message, data]) where message and data are optional
252
+ logEntry(moduleSelf, severity, req, eventType, message, data) {
253
+ if (!self.shouldKeepEntry(moduleSelf, severity, req, eventType)) {
254
+ return;
255
+ }
256
+ const args = self.processLoggerArgs(
257
+ moduleSelf,
258
+ severity,
259
+ req,
260
+ eventType,
261
+ message,
262
+ data
263
+ );
264
+ self.apos.util[severity](...args);
265
+ },
266
+ // An internal method, do not use it directly.
267
+ // Do a minimal computation and validation of the arguments - logs should
268
+ // be fast. The function exepects 4 or 5 arguments (without counting the first optional `moduleSelf` argument),
269
+ // but all of the below will work:
270
+ // processLogArgs(moduleSelf, severity = 'info', eventType = 'event-type');
271
+ // processLogArgs(moduleSelf, severity = 'info', eventType = 'event-type', data = { module: 'my-module' });
272
+ // processLogArgs(moduleSelf, severity = 'info', eventType = 'event-type', message = 'message', data = { module: 'my-module' });
273
+ // processLogArgs(moduleSelf, severity = 'info', req, eventType = 'event-type', [...messageAndOrData]);
274
+ //
275
+ // If `moduleSelf` is provided, it is used to extract the module name from
276
+ // the `__meta` property.
277
+ // `severity` and `eventType` are required.
278
+ // `req`, `message` and `data` are optional.
279
+ // `req` is an apos request object. If provided, the `data` argument will be
280
+ // enriched with additional information from the request.
281
+ // `data.module` is recognized as a special property and is used to refortmat
282
+ // the message.
283
+ // `data.severity` is always set to the value of the `severity` argument.
284
+ // `data.type` is always set to the value of the `eventType` argument.
285
+ // message is optional, if not provided it is generated from the eventType and data.module.
286
+ // Optional message values:
287
+ // - `module: eventType: message`
288
+ // - `eventType: message`: (missing module)
289
+ // - `module: eventType`: (missing message)
290
+ // - `eventType`: (missing module and message)
291
+ //
292
+ // Returns [ data ] or [ message, data ] depending on option.messageAs.
293
+ // `data` is always an object containing at least a `type` and `severity` properties.
294
+ processLoggerArgs(moduleSelf, severity, ...args) {
295
+ let req;
296
+ let eventType;
297
+ let message;
298
+ let obj;
299
+ const data = {};
300
+
301
+ // Detect `req` argument with a simple duck type check - apos `req` object
302
+ // has always a translate `t` function.
303
+ if (args[0] && typeof args[0].t === 'function') {
304
+ [ req, eventType, message, obj ] = args;
305
+ } else {
306
+ [ eventType, message, obj ] = args;
307
+ }
308
+
309
+ if (typeof severity !== 'string') {
310
+ throw new Error('Severity must be a string.');
311
+ }
312
+ if (typeof eventType !== 'string') {
313
+ throw new Error('Event type must be a string');
314
+ }
315
+ if (_.isPlainObject(message)) {
316
+ obj = message;
317
+ message = undefined;
318
+ }
319
+ obj = obj ? { ...obj } : {};
320
+ const aposModule = moduleSelf.__meta?.name ?? '__unknown__';
321
+
322
+ if (typeof message === 'string' && message.trim().length > 0) {
323
+ message = aposModule
324
+ ? `${aposModule}: ${eventType}: ${message}`
325
+ : `${eventType}: ${message}`;
326
+ } else {
327
+ message = aposModule
328
+ ? `${aposModule}: ${eventType}`
329
+ : eventType;
330
+ }
331
+ if (self.options.messageAs) {
332
+ data[self.options.messageAs] = message;
333
+ delete obj[self.options.messageAs];
334
+ }
335
+ // Preserve the property order.
336
+ data.module = aposModule;
337
+ data.type = eventType;
338
+ data.severity = severity;
339
+
340
+ self.processRequestData(req, data);
341
+ // Don't override system properties.
342
+ Object.assign(data, obj);
343
+ data.module = aposModule;
344
+ data.type = eventType;
345
+ data.severity = severity;
346
+
347
+ return self.options.messageAs ? [ data ] : [ message, data ];
348
+ },
349
+
350
+ // Enrich the `data` argument with additional information from the request.
351
+ processRequestData(req, data) {
352
+ if (!req) {
353
+ return data;
354
+ }
355
+
356
+ data.url = req.originalUrl;
357
+ data.path = req.path;
358
+ data.method = req.method;
359
+ data.ip = self.getIp(req);
360
+ data.query = req.query;
361
+ data.requestId = self.getRequestId(req);
362
+ },
363
+
364
+ // Helper to get the IP address from the request.
365
+ // Can be overriden.
366
+ getIp(req) {
367
+ return req.ip;
368
+ },
369
+
370
+ // Helper to get unique request id. It will be generated (once) if not present.
371
+ // Can be refatored to express level in the future.
372
+ getRequestId(req) {
373
+ if (!req.requestId) {
374
+ req.requestId = self.apos.util.generateId();
375
+ }
376
+ return req.requestId;
377
+ },
378
+
379
+ // Assess the module filter configuration and determine if the log
380
+ // should be kept or rejected.
381
+ //
382
+ // `moduleSelf` and `args` arguments should be the same as
383
+ // passed to `processLogArgs(...)`.
384
+ //
385
+ // The module and global configs are merged.
386
+ // The logic is as follows:
387
+ // - if severity is matched, keep the log (no matter the event type)
388
+ // - if eventType is matched, keep the log (no matter the severity)
389
+ shouldKeepEntry(moduleSelf, ...args) {
390
+ // Detect severity and eventType from the arguments.
391
+ let severity;
392
+ let req;
393
+ let eventType;
394
+ if (args[1] && typeof args[1].t === 'function') {
395
+ // eslint-disable-next-line no-unused-vars
396
+ [ severity, req, eventType ] = args;
397
+ } else {
398
+ [ severity, eventType ] = args;
399
+ }
400
+ const aposModule = moduleSelf.__meta?.name ?? '__unknown__';
401
+ const cacheId = `${aposModule}:${severity}:${eventType}`;
402
+ if (typeof self.filterCache[cacheId] !== 'undefined') {
403
+ return self.filterCache[cacheId];
404
+ }
405
+
406
+ // Consolidate and match severity and event type.
407
+ const severityArr = [
408
+ ...new Set([
409
+ ...self.filters['*'].severity,
410
+ ...(self.filters[aposModule]?.severity || [])
411
+ ])
412
+ ];
413
+ const eventsArr = [
414
+ ...new Set([
415
+ ...(self.filters['*'].events || []),
416
+ ...(self.filters[aposModule]?.events || [])
417
+ ])
418
+ ];
419
+ const severityMatch = severityArr.includes(severity);
420
+ const eventsMatch = eventsArr.length > 0
421
+ ? eventsArr.includes(eventType) || eventsArr.includes('*')
422
+ : severityMatch;
423
+
424
+ self.filterCache[cacheId] = severityMatch || eventsMatch;
425
+ return self.filterCache[cacheId];
426
+ }
427
+ };
428
+ }
429
+ };
@@ -480,8 +480,10 @@ module.exports = {
480
480
  //
481
481
  // If the user's login SUCCEEDS, the return value is
482
482
  // the `user` object.
483
+ // `attempts`, `ip` and `requestId` are optional, sent for only logging needs. They won't
484
+ // be available with passport.
483
485
 
484
- async verifyLogin(username, password) {
486
+ async verifyLogin(username, password, attempts = 0, ip, requestId) {
485
487
  const req = self.apos.task.getReq();
486
488
  const user = await self.apos.user.find(req, {
487
489
  $or: [
@@ -492,14 +494,32 @@ module.exports = {
492
494
  }).toObject();
493
495
 
494
496
  if (!user) {
497
+ self.logInfo('incorrect-username', {
498
+ username,
499
+ ip,
500
+ attempts: attempts + 1,
501
+ requestId
502
+ });
495
503
  await Promise.delay(1000);
496
504
  return false;
497
505
  }
498
506
  try {
499
507
  await self.apos.user.verifyPassword(user, password);
508
+ self.logInfo('correct-password', {
509
+ username,
510
+ ip,
511
+ attempts: attempts,
512
+ requestId
513
+ });
500
514
  return user;
501
515
  } catch (err) {
502
516
  if (err.name === 'invalid') {
517
+ self.logInfo('incorrect-password', {
518
+ username,
519
+ ip,
520
+ attempts: attempts + 1,
521
+ requestId
522
+ });
503
523
  await Promise.delay(1000);
504
524
  return false;
505
525
  } else {
@@ -642,6 +662,10 @@ module.exports = {
642
662
  _id: token.userId
643
663
  });
644
664
  await self.passportLogin(req, user);
665
+ // No access to login attempts in the final phase.
666
+ self.logInfo(req, 'complete', {
667
+ username: user.username
668
+ });
645
669
  } else {
646
670
  delete token.requirementsToVerify;
647
671
  self.bearerTokens.updateOne(token, {
@@ -649,6 +673,9 @@ module.exports = {
649
673
  requirementsToVerify: 1
650
674
  }
651
675
  });
676
+ self.logInfo(req, 'complete', {
677
+ username: user.username
678
+ });
652
679
  return {
653
680
  token
654
681
  };
@@ -697,6 +724,7 @@ module.exports = {
697
724
  }
698
725
 
699
726
  const { cachedAttempts, reached } = await self.checkLoginAttempts(username);
727
+ const logAttempts = cachedAttempts ?? 0;
700
728
 
701
729
  if (reached) {
702
730
  throw self.apos.error('invalid', req.t('apostrophe:loginMaxAttemptsReached', {
@@ -716,7 +744,14 @@ module.exports = {
716
744
  throw e;
717
745
  }
718
746
  }
719
- const user = await self.apos.login.verifyLogin(username, password);
747
+ // send log information
748
+ const user = await self.apos.login.verifyLogin(
749
+ username,
750
+ password,
751
+ logAttempts,
752
+ self.apos.structuredLog.getIp(req),
753
+ self.apos.structuredLog.getRequestId(req)
754
+ );
720
755
  if (!user) {
721
756
  // For security reasons we may not tell the user which case applies
722
757
  throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
@@ -727,7 +762,7 @@ module.exports = {
727
762
  if (requirementsToVerify.length) {
728
763
  const token = cuid();
729
764
 
730
- await self.bearerTokens.insert({
765
+ await self.bearerTokens.insertOne({
731
766
  _id: token,
732
767
  userId: user._id,
733
768
  requirementsToVerify,
@@ -747,16 +782,24 @@ module.exports = {
747
782
  if (session) {
748
783
  await self.passportLogin(req, user);
749
784
  await self.clearLoginAttempts(user.username);
785
+ self.logInfo(req, 'complete', {
786
+ username,
787
+ attempts: logAttempts
788
+ });
750
789
  return {};
751
790
  } else {
752
791
  const token = cuid();
753
- await self.bearerTokens.insert({
792
+ await self.bearerTokens.insertOne({
754
793
  _id: token,
755
794
  userId: user._id,
756
795
  expires: new Date(new Date().getTime() + (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000)
757
796
  });
758
797
 
759
798
  await self.clearLoginAttempts(user.username);
799
+ self.logInfo(req, 'complete', {
800
+ username,
801
+ attempts: logAttempts
802
+ });
760
803
 
761
804
  return {
762
805
  token
@@ -127,6 +127,10 @@ export default {
127
127
  checkedCount: {
128
128
  type: Number,
129
129
  required: true
130
+ },
131
+ moduleName: {
132
+ type: String,
133
+ required: true
130
134
  }
131
135
  },
132
136
  emits: [
@@ -263,7 +267,16 @@ export default {
263
267
  async beginGroupedOperation(action, operations) {
264
268
  const operation = operations.find(o => o.action === action);
265
269
 
266
- await this.confirmOperation(operation);
270
+ operation.modal ? await this.modalOperation(operation) : await this.confirmOperation(operation);
271
+ },
272
+ async modalOperation({
273
+ modal, ...rest
274
+ }) {
275
+ await apos.modal.execute(modal, {
276
+ count: this.checkedCount,
277
+ moduleName: this.moduleName,
278
+ ...rest
279
+ });
267
280
  },
268
281
  async confirmOperation ({
269
282
  modalOptions = {}, action, operations, label, ...rest
@@ -108,7 +108,7 @@ export default {
108
108
  // 'utility' or 'other', or the entire schema if followedByCategory
109
109
  // is falsy
110
110
  getFieldsByCategory(followedByCategory) {
111
- if (followedByCategory) {
111
+ if (followedByCategory && this.utilityFields) {
112
112
  return (followedByCategory === 'other')
113
113
  ? this.schema.filter(field => !this.utilityFields.includes(field.name))
114
114
  : this.schema.filter(field => this.utilityFields.includes(field.name));
@@ -57,6 +57,7 @@ module.exports = {
57
57
 
58
58
  self.__helpers = {};
59
59
  self.templateData = self.options.templateData || {};
60
+ self.__structuredLoggingEnabled = false;
60
61
 
61
62
  if (self.apos.asset) {
62
63
  if (!self.apos.asset.chains) {
@@ -74,6 +75,11 @@ module.exports = {
74
75
  // Routes in their final ready-to-add-to-Express form
75
76
  self._routes = [];
76
77
 
78
+ // Enable structured logging after util module is initialized.
79
+ if (self.apos.util && (self.apos.util !== self)) {
80
+ self.__structuredLoggingEnabled = true;
81
+ }
82
+
77
83
  // Add i18next phrases if we started up after the i18n module,
78
84
  // which will call this for us if we start up before it
79
85
  if (self.apos.i18n && (self.apos.i18n !== self)) {
@@ -89,6 +95,10 @@ module.exports = {
89
95
 
90
96
  methods(self) {
91
97
  return {
98
+ // `self.logInfo`, `self.logError`, etc. available for every module except
99
+ // `error`, `util` and the `log` module itself.
100
+ ...require('./lib/log')(self),
101
+
92
102
  compileSectionRoutes(section) {
93
103
  _.each(self[section] || {}, function(routes, method) {
94
104
  _.each(routes, function(config, name) {
@@ -231,18 +241,34 @@ module.exports = {
231
241
  });
232
242
  }
233
243
  const response = getResponse(err);
234
- // err.stack includes basic description of error
235
- if (Object.keys(response.data).length > 1) {
236
- response.fn(`${req.method} ${req.url}: \n\n${err.stack}\n\n${JSON.stringify(response.data, null, ' ')}`);
237
- } else {
238
- response.fn(req.method + ' ' + req.url + ': ' + '\n\n' + err.stack);
239
- }
244
+ logError(req, response, err);
240
245
  req.res.status(response.code);
241
246
  return req.res.send({
242
247
  name: response.name,
243
248
  data: response.data,
244
249
  message: response.message
245
250
  });
251
+ function logError(req, response, error) {
252
+ const typeTrail = response.code === 500 ? '' : `-${response.name}`;
253
+ // Log the actual error, not the message meant for the browser.
254
+ const msg = response.code === 500
255
+ ? err.message
256
+ : response.message;
257
+ try {
258
+ self.logError(req, `api-error${typeTrail}`, msg, {
259
+ name: response.name,
260
+ status: response.code,
261
+ stack: error.stack.split('\n').slice(1).map(line => line.trim()),
262
+ errorPath: response.path,
263
+ data: response.data
264
+ });
265
+ } catch (e) {
266
+ // We can't afford to throw here, it would hang the response.
267
+ e.message = 'Structured logging error: ' + e.message;
268
+ // eslint-disable-next-line no-console
269
+ console.error(e);
270
+ }
271
+ }
246
272
  function getResponse(err) {
247
273
  let name, data, code, fn, message, path;
248
274
  if (err && err.name && self.apos.http.errors[err.name]) {