@jsreport/jsreport-core 3.4.1 → 3.6.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.
@@ -5,7 +5,7 @@
5
5
  */
6
6
  const path = require('path')
7
7
  const { Readable } = require('stream')
8
- const Reaper = require('reap2')
8
+ const Reaper = require('@jsreport/reap')
9
9
  const optionsLoad = require('./optionsLoad')
10
10
  const { createLogger, configureLogger, silentLogs } = require('./logger')
11
11
  const checkEntityName = require('./validateEntityName')
@@ -28,10 +28,10 @@ const Reporter = require('../shared/reporter')
28
28
  const Request = require('./request')
29
29
  const generateRequestId = require('../shared/generateRequestId')
30
30
  const Profiler = require('./profiler')
31
- const Monitoring = require('./monitoring')
32
31
  const migrateXlsxTemplatesToAssets = require('./migration/xlsxTemplatesToAssets')
33
32
  const migrateResourcesToAssets = require('./migration/resourcesToAssets')
34
33
  const semver = require('semver')
34
+ let reportCounter = 0
35
35
 
36
36
  class MainReporter extends Reporter {
37
37
  constructor (options, defaults) {
@@ -69,6 +69,7 @@ class MainReporter extends Reporter {
69
69
 
70
70
  this.logger = createLogger()
71
71
  this.beforeMainActionListeners = this.createListenerCollection('beforeMainAction')
72
+ this.beforeRenderWorkerAllocatedListeners = this.createListenerCollection('beforeRenderWorkerAllocated')
72
73
  }
73
74
 
74
75
  discover () {
@@ -89,8 +90,8 @@ class MainReporter extends Reporter {
89
90
  return this
90
91
  }
91
92
 
92
- async extensionsLoad (opts) {
93
- const appliedConfigFile = await optionsLoad({
93
+ async extensionsLoad (_opts = {}) {
94
+ const [explicitOptions, appliedConfigFile] = await optionsLoad({
94
95
  defaults: this.defaults,
95
96
  options: this.options,
96
97
  validator: this.optionsValidator,
@@ -105,6 +106,8 @@ class MainReporter extends Reporter {
105
106
  silentLogs(this.logger)
106
107
  }
107
108
 
109
+ const { onConfigDetails, ...opts } = _opts
110
+
108
111
  this.logger.info(`Initializing jsreport (version: ${this.version}, configuration file: ${appliedConfigFile || 'none'}, nodejs: ${process.versions.node})`)
109
112
 
110
113
  await this.extensionsManager.load(opts)
@@ -129,6 +132,10 @@ class MainReporter extends Reporter {
129
132
  throw new Error(`options contain values that does not match the defined full root schema. ${rootOptionsValidation.fullErrorMessage}`)
130
133
  }
131
134
 
135
+ if (typeof onConfigDetails === 'function') {
136
+ onConfigDetails(explicitOptions)
137
+ }
138
+
132
139
  return this
133
140
  }
134
141
 
@@ -156,6 +163,7 @@ class MainReporter extends Reporter {
156
163
  */
157
164
  async init () {
158
165
  this.closing = this.closed = false
166
+
159
167
  if (this._initialized || this._initializing) {
160
168
  throw new Error('jsreport already initialized or just initializing. Make sure init is called only once')
161
169
  }
@@ -165,14 +173,23 @@ class MainReporter extends Reporter {
165
173
  this._initializing = true
166
174
 
167
175
  if (this.compilation) {
168
- this.compilation.resource('sandbox.js', require.resolve('vm2/lib/sandbox.js'))
169
- this.compilation.resource('contextify.js', require.resolve('vm2/lib/contextify.js'))
170
- this.compilation.resource('fixasync.js', require.resolve('vm2/lib/fixasync.js'))
176
+ this.compilation.resource('vm2-events.js', require.resolve('vm2/lib/events.js'))
177
+ this.compilation.resource('vm2-resolver-compat.js', require.resolve('vm2/lib/resolver-compat.js'))
178
+ this.compilation.resource('vm2-resolver.js', require.resolve('vm2/lib/resolver.js'))
179
+ this.compilation.resource('vm2-setup-node-sandbox.js', require.resolve('vm2/lib/setup-node-sandbox.js'))
180
+ this.compilation.resource('vm2-setup-sandbox.js', require.resolve('vm2/lib/setup-sandbox.js'))
171
181
  }
172
182
 
173
183
  try {
174
184
  this._registerLogMainAction()
175
- await this.extensionsLoad()
185
+
186
+ let explicitOptions
187
+
188
+ await this.extensionsLoad({
189
+ onConfigDetails: (_explicitOptions) => {
190
+ explicitOptions = _explicitOptions
191
+ }
192
+ })
176
193
 
177
194
  this.documentStore = DocumentStore(Object.assign({}, this.options, { logger: this.logger }), this.entityTypeValidator, this.encryption)
178
195
  documentStoreActions(this)
@@ -180,7 +197,6 @@ class MainReporter extends Reporter {
180
197
  blobStorageActions(this)
181
198
  Templates(this)
182
199
  Profiler(this)
183
- Monitoring(this)
184
200
 
185
201
  this.folders = Object.assign(this.folders, Folders(this))
186
202
 
@@ -198,6 +214,14 @@ class MainReporter extends Reporter {
198
214
 
199
215
  await this.extensionsManager.init()
200
216
 
217
+ if (this.options.trustUserCode) {
218
+ this.logger.info('Code sandboxing is disabled, users can potentially penetrate the local system if you allow code from external users to be part of your reports')
219
+ }
220
+
221
+ if (explicitOptions.trustUserCode == null && explicitOptions.allowLocalFilesAccess != null) {
222
+ this.logger.warn('options.allowLocalFilesAccess is deprecated, use options.trustUserCode instead')
223
+ }
224
+
201
225
  this.logger.info(`Using general timeout for rendering (reportTimeout: ${this.options.reportTimeout})`)
202
226
 
203
227
  if (this.options.store.provider === 'memory') {
@@ -267,8 +291,6 @@ class MainReporter extends Reporter {
267
291
  name: 'none'
268
292
  })
269
293
 
270
- this.monitoring.init()
271
-
272
294
  this.logger.info('reporter initialized')
273
295
  this._initialized = true
274
296
  this._initExecution.resolve()
@@ -306,6 +328,34 @@ class MainReporter extends Reporter {
306
328
  await validateReservedName(this, c, doc)
307
329
  }
308
330
 
331
+ async _handleRenderError (req, res, err) {
332
+ if (err.code === 'WORKER_TIMEOUT') {
333
+ err.message = 'Report timeout'
334
+ if (req.context.profiling?.lastOperation != null && req.context.profiling?.entity != null) {
335
+ err.message += `. Last profiler operation: (${req.context.profiling.lastOperation.subtype}) ${req.context.profiling.lastOperation.name}`
336
+ }
337
+
338
+ if (req.context.http != null) {
339
+ const profileUrl = `${req.context.http.baseUrl}/studio/profiles/${req.context.profiling.entity._id}`
340
+ err.message += `. You can inspect and find more details here: ${profileUrl}`
341
+ }
342
+
343
+ err.weak = true
344
+ }
345
+
346
+ if (err.code === 'WORKER_ABORTED') {
347
+ err.message = 'Report cancelled'
348
+ err.weak = true
349
+ }
350
+
351
+ if (!err.logged) {
352
+ const logFn = err.weak ? this.logger.warn : this.logger.error
353
+ logFn(`Report render failed: ${err.message}${err.stack != null ? ' ' + err.stack : ''}`, req)
354
+ }
355
+ await this.renderErrorListeners.fire(req, res, err)
356
+ throw err
357
+ }
358
+
309
359
  /**
310
360
  * Main method for invoking rendering
311
361
  * render({ template: { content: 'foo', engine: 'none', recipe: 'html' }, data: { foo: 'hello' } })
@@ -324,23 +374,30 @@ class MainReporter extends Reporter {
324
374
  req.context = Object.assign({}, req.context)
325
375
  req.context.rootId = req.context.rootId || generateRequestId()
326
376
  req.context.id = req.context.rootId
377
+ req.context.reportCounter = ++reportCounter
378
+ req.context.startTimestamp = new Date().getTime()
327
379
 
328
- const worker = options.worker || await this._workersManager.allocate(req, {
329
- timeout: this.options.reportTimeout
330
- })
331
-
332
- let keepWorker
380
+ let worker
333
381
  let workerAborted
382
+ let dontCloseProcessing
383
+ const res = { meta: {} }
384
+ try {
385
+ await this.beforeRenderWorkerAllocatedListeners.fire(req)
334
386
 
335
- if (options.abortEmitter) {
336
- options.abortEmitter.once('abort', () => {
337
- workerAborted = true
338
- worker.release(req).catch((e) => this.logger.error('Failed to release worker ' + e))
387
+ worker = await this._workersManager.allocate(req, {
388
+ timeout: this.getReportTimeout(req)
339
389
  })
340
- }
341
390
 
342
- const res = { meta: {} }
343
- try {
391
+ if (options.abortEmitter) {
392
+ options.abortEmitter.once('abort', () => {
393
+ if (workerAborted) {
394
+ return
395
+ }
396
+ workerAborted = true
397
+ worker.release(req).catch((e) => this.logger.error('Failed to release worker ' + e))
398
+ })
399
+ }
400
+
344
401
  if (workerAborted) {
345
402
  throw this.createError('Request aborted by client')
346
403
  }
@@ -351,7 +408,7 @@ class MainReporter extends Reporter {
351
408
  req,
352
409
  data: {}
353
410
  }, {
354
- timeout: this.options.reportTimeout
411
+ timeout: this.getReportTimeout(req)
355
412
  })
356
413
  req = result
357
414
  }
@@ -369,39 +426,40 @@ class MainReporter extends Reporter {
369
426
  }
370
427
  }
371
428
 
372
- let reportTimeout = this.options.reportTimeout
373
-
374
- if (
375
- this.options.enableRequestReportTimeout &&
376
- req.options &&
377
- req.options.timeout != null
378
- ) {
379
- reportTimeout = req.options.timeout
380
- }
429
+ const reportTimeout = this.getReportTimeout(req)
381
430
 
382
431
  await this.beforeRenderListeners.fire(req, res, { worker })
383
432
 
384
- // this is used so far just in the reports extension
385
- // it wants to send to the client immediate response with link to the report status
386
- // but the previous steps already allocated worker which has the parsed input request
387
- // so we need to keep the worker active and let the subsequent real render call use it
388
- // we cant move the main beforeRenderListener before the worker allocation, because at that point
389
- // the request isn't parsed and we don't know the template and options
390
- if (req.context.returnResponseAndKeepWorker) {
391
- keepWorker = true
392
- res.stream = Readable.from(res.content)
393
-
394
- // just temporary workaround until we change how report render works
395
- await this.documentStore.collection('profiles').update({
396
- _id: req.context.profiling.entity._id
397
- }, {
398
- $set: {
399
- state: 'success',
400
- finishedOn: new Date(),
401
- blobPersisted: true
433
+ if (workerAborted) {
434
+ throw this.createError('Request aborted by client')
435
+ }
436
+
437
+ if (req.context.clientNotification) {
438
+ process.nextTick(async () => {
439
+ try {
440
+ const responseResult = await this.executeWorkerAction('render', {}, {
441
+ timeout: reportTimeout + this.options.reportTimeoutMargin,
442
+ worker
443
+ }, req)
444
+
445
+ Object.assign(res, responseResult)
446
+ await this.afterRenderListeners.fire(req, res)
447
+ } catch (err) {
448
+ await this._handleRenderError(req, res, err).catch((e) => {})
449
+ } finally {
450
+ if (!workerAborted) {
451
+ await worker.release(req)
452
+ }
402
453
  }
403
- }, req)
404
- return res
454
+ })
455
+
456
+ dontCloseProcessing = true
457
+ const r = {
458
+ ...req.context.clientNotification,
459
+ stream: Readable.from(req.context.clientNotification.content)
460
+ }
461
+ delete req.context.clientNotification
462
+ return r
405
463
  }
406
464
 
407
465
  if (workerAborted) {
@@ -418,33 +476,10 @@ class MainReporter extends Reporter {
418
476
  res.stream = Readable.from(res.content)
419
477
  return res
420
478
  } catch (err) {
421
- if (err.code === 'WORKER_TIMEOUT') {
422
- err.message = 'Report timeout'
423
- if (req.context.profiling?.lastOperation != null && req.context.profiling?.entity != null) {
424
- err.message += `. Last profiler operation: (${req.context.profiling.lastOperation.subtype}) ${req.context.profiling.lastOperation.name}`
425
- }
426
-
427
- if (req.context.http != null) {
428
- const profileUrl = `${req.context.http.baseUrl}/studio/profiles/${req.context.profiling.entity._id}`
429
- err.message += `. You can inspect and find more details here: ${profileUrl}`
430
- }
431
-
432
- err.weak = true
433
- }
434
-
435
- if (err.code === 'WORKER_ABORTED') {
436
- err.message = 'Report cancelled'
437
- err.weak = true
438
- }
439
-
440
- if (!err.logged) {
441
- const logFn = err.weak ? this.logger.warn : this.logger.error
442
- logFn(`Report render failed: ${err.message}${err.stack != null ? ' ' + err.stack : ''}`, req)
443
- }
444
- await this.renderErrorListeners.fire(req, res, err)
479
+ await this._handleRenderError(req, res, err)
445
480
  throw err
446
481
  } finally {
447
- if (!workerAborted && !keepWorker) {
482
+ if (worker && !workerAborted && !dontCloseProcessing) {
448
483
  await worker.release(req)
449
484
  }
450
485
  }
@@ -3,6 +3,7 @@ const set = require('lodash.set')
3
3
  const hasOwn = require('has-own-deep')
4
4
  const unsetValue = require('unset-value')
5
5
  const ms = require('ms')
6
+ const bytes = require('bytes')
6
7
  const Ajv = require('ajv')
7
8
 
8
9
  const validatorCollection = new WeakMap()
@@ -126,6 +127,35 @@ class SchemaValidator {
126
127
  }
127
128
  })
128
129
 
130
+ validator.addKeyword('$jsreport-acceptsSize', {
131
+ modifying: true,
132
+ compile: (sch) => {
133
+ if (sch !== true) {
134
+ return () => true
135
+ }
136
+
137
+ return (data, dataPath, parentData, parentDataProperty) => {
138
+ if (typeof data !== 'string' && typeof data !== 'number') {
139
+ return false
140
+ }
141
+
142
+ if (typeof data === 'number') {
143
+ return true
144
+ }
145
+
146
+ const newData = bytes(data)
147
+
148
+ if (newData == null) {
149
+ return false
150
+ }
151
+
152
+ parentData[parentDataProperty] = newData
153
+
154
+ return true
155
+ }
156
+ }
157
+ })
158
+
129
159
  let rootValidate
130
160
 
131
161
  if (options.rootSchema != null) {
@@ -25,7 +25,7 @@ Settings.prototype.get = function (key) {
25
25
  }
26
26
 
27
27
  Settings.prototype.findValue = async function (key, req) {
28
- const res = await this.documentStore.collection('settings').find({ key: key }, req)
28
+ const res = await this.documentStore.collection('settings').find({ key: key }, localReqWithoutAuthorization(req))
29
29
  if (res.length !== 1) {
30
30
  return null
31
31
  }
@@ -49,7 +49,6 @@ Settings.prototype.addOrSet = async function (key, avalue, req) {
49
49
  const value = typeof avalue !== 'string' ? JSON.stringify(avalue) : avalue
50
50
 
51
51
  const updateCount = await this.documentStore.collection('settings').update({ key }, { $set: { key: key, value: value } }, localReqWithoutAuthorization(req))
52
-
53
52
  if (updateCount === 0) {
54
53
  await this.documentStore.collection('settings').insert({ key: key, value: value }, localReqWithoutAuthorization(req))
55
54
  return 1
@@ -86,16 +86,18 @@ module.exports = (entitySet, provider, model, validator, encryption, transaction
86
86
  validateEntityName(data.name)
87
87
  }
88
88
 
89
- const entityType = model.entitySets[entitySet] ? model.entitySets[entitySet].normalizedEntityTypeName : null
89
+ if (req == null || req.context.skipValidationFor !== data) {
90
+ const entityType = model.entitySets[entitySet] ? model.entitySets[entitySet].normalizedEntityTypeName : null
90
91
 
91
- if (entityType != null && validator.getSchema(entityType) != null) {
92
- const validationResult = validator.validate(entityType, data)
92
+ if (entityType != null && validator.getSchema(entityType) != null) {
93
+ const validationResult = validator.validate(entityType, data)
93
94
 
94
- if (!validationResult.valid) {
95
- throw createError(`Error when trying to insert into "${entitySet}" collection. input contain values that does not match the schema. ${validationResult.fullErrorMessage}`, {
96
- weak: true,
97
- statusCode: 400
98
- })
95
+ if (!validationResult.valid) {
96
+ throw createError(`Error when trying to insert into "${entitySet}" collection. input contain values that does not match the schema. ${validationResult.fullErrorMessage}`, {
97
+ weak: true,
98
+ statusCode: 400
99
+ })
100
+ }
99
101
  }
100
102
  }
101
103
 
@@ -372,6 +372,10 @@ const DocumentStore = (options, validator, encryption) => {
372
372
  },
373
373
 
374
374
  async beginTransaction (req) {
375
+ if (this.options.store?.transactions?.enabled === false) {
376
+ return
377
+ }
378
+
375
379
  if (req.context.storeTransaction && transactions.has(req.context.storeTransaction)) {
376
380
  throw new Error('Can not call store.beginTransaction when an active transaction already exists, make sure you are not calling store.beginTransaction more than once')
377
381
  }
@@ -386,6 +390,10 @@ const DocumentStore = (options, validator, encryption) => {
386
390
  },
387
391
 
388
392
  async commitTransaction (req) {
393
+ if (this.options.store?.transactions?.enabled === false) {
394
+ return
395
+ }
396
+
389
397
  const tranId = req.context.storeTransaction
390
398
  const tran = transactions.get(tranId)
391
399
 
@@ -400,6 +408,10 @@ const DocumentStore = (options, validator, encryption) => {
400
408
  },
401
409
 
402
410
  async rollbackTransaction (req) {
411
+ if (this.options.store?.transactions?.enabled === false) {
412
+ return
413
+ }
414
+
403
415
  const tranId = req.context.storeTransaction
404
416
  const tran = transactions.get(tranId)
405
417
 
@@ -411,6 +423,13 @@ const DocumentStore = (options, validator, encryption) => {
411
423
 
412
424
  transactions.delete(tranId)
413
425
  delete req.context.storeTransaction
426
+ },
427
+
428
+ generateId () {
429
+ if (this.provider.generateId) {
430
+ return this.provider.generateId()
431
+ }
432
+ return uuidv4()
414
433
  }
415
434
  }
416
435
 
@@ -3,10 +3,16 @@ module.exports = (reporter) => {
3
3
  reporter.initializeListeners.add('core-validate-id', () => {
4
4
  for (const c of Object.keys(reporter.documentStore.collections)) {
5
5
  reporter.documentStore.collection(c).beforeInsertListeners.add('validate-id', (doc, req) => {
6
- return validateIdForStoreChange(reporter, c, doc._id, undefined, req)
6
+ if (req == null || req.context.skipValidationFor !== doc) {
7
+ return validateIdForStoreChange(reporter, c, doc._id, undefined, req)
8
+ }
7
9
  })
8
10
 
9
11
  reporter.documentStore.collection(c).beforeUpdateListeners.add('validate-id', async (q, update, opts, req) => {
12
+ if (req != null && req.context.skipValidationFor === update) {
13
+ return
14
+ }
15
+
10
16
  if (update.$set && opts && opts.upsert === true) {
11
17
  await validateIdForStoreChange(reporter, c, update.$set._id, undefined, req)
12
18
  }
@@ -31,6 +31,10 @@ module.exports = (reporter) => {
31
31
  }
32
32
 
33
33
  async function validateShortid (reporter, collectionName, doc, originalIdValue, req) {
34
+ if (req != null && req.context.skipValidationFor === doc) {
35
+ return
36
+ }
37
+
34
38
  const shortid = doc.shortid
35
39
 
36
40
  if (!shortid) {
@@ -13,7 +13,7 @@ module.exports = (level, msg, meta) => {
13
13
 
14
14
  // TODO adding cancel looks bad, its before script is adding req.cancel()
15
15
  // excluding non relevant properties for the log
16
- const newMeta = Object.assign({}, omit(meta, ['template', 'options', 'data', 'context', 'timestamp', 'cancel']))
16
+ const newMeta = Object.assign({}, omit(meta, ['rawContent', 'template', 'options', 'data', 'context', 'timestamp', 'cancel']))
17
17
 
18
18
  if (newMeta.rootId == null && meta.context.rootId != null) {
19
19
  newMeta.rootId = meta.context.rootId
@@ -51,6 +51,22 @@ class Reporter extends EventEmitter {
51
51
  return generateRequestId()
52
52
  }
53
53
 
54
+ /**
55
+ * @public Ensures that we get the proper report timeout in case when custom timeout per request was enabled
56
+ */
57
+ getReportTimeout (req) {
58
+ const elapsedTime = req.context.startTimestamp ? (new Date().getTime() - req.context.startTimestamp) : 0
59
+ if (
60
+ this.options.enableRequestReportTimeout &&
61
+ req.options != null &&
62
+ req.options.timeout != null
63
+ ) {
64
+ return Math.max(0, req.options.timeout - elapsedTime)
65
+ }
66
+
67
+ return Math.max(0, this.options.reportTimeout - elapsedTime)
68
+ }
69
+
54
70
  /**
55
71
  * Ensures that the jsreport auto-cleanup temp directory (options.tempAutoCleanupDirectory) exists by doing a mkdir call
56
72
  *