@radio-garden/ditojs-server 2.85.2-0.5067ad799

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 (137) hide show
  1. package/README.md +6 -0
  2. package/package.json +95 -0
  3. package/src/app/Application.js +1186 -0
  4. package/src/app/Validator.js +405 -0
  5. package/src/app/index.js +2 -0
  6. package/src/cli/console.js +152 -0
  7. package/src/cli/db/createMigration.js +241 -0
  8. package/src/cli/db/index.js +7 -0
  9. package/src/cli/db/listAssetConfig.js +10 -0
  10. package/src/cli/db/migrate.js +12 -0
  11. package/src/cli/db/reset.js +23 -0
  12. package/src/cli/db/rollback.js +12 -0
  13. package/src/cli/db/seed.js +80 -0
  14. package/src/cli/db/unlock.js +9 -0
  15. package/src/cli/index.js +72 -0
  16. package/src/controllers/AdminController.js +322 -0
  17. package/src/controllers/CollectionController.js +274 -0
  18. package/src/controllers/Controller.js +657 -0
  19. package/src/controllers/ControllerAction.js +370 -0
  20. package/src/controllers/MemberAction.js +27 -0
  21. package/src/controllers/ModelController.js +63 -0
  22. package/src/controllers/RelationController.js +93 -0
  23. package/src/controllers/UsersController.js +64 -0
  24. package/src/controllers/index.js +5 -0
  25. package/src/errors/AssetError.js +7 -0
  26. package/src/errors/AuthenticationError.js +7 -0
  27. package/src/errors/AuthorizationError.js +7 -0
  28. package/src/errors/ControllerError.js +14 -0
  29. package/src/errors/DatabaseError.js +37 -0
  30. package/src/errors/GraphError.js +7 -0
  31. package/src/errors/ModelError.js +12 -0
  32. package/src/errors/NotFoundError.js +7 -0
  33. package/src/errors/NotImplementedError.js +7 -0
  34. package/src/errors/QueryBuilderError.js +7 -0
  35. package/src/errors/RelationError.js +21 -0
  36. package/src/errors/ResponseError.js +56 -0
  37. package/src/errors/ValidationError.js +7 -0
  38. package/src/errors/index.js +13 -0
  39. package/src/graph/DitoGraphProcessor.js +213 -0
  40. package/src/graph/expression.js +53 -0
  41. package/src/graph/graph.js +258 -0
  42. package/src/graph/index.js +3 -0
  43. package/src/index.js +9 -0
  44. package/src/lib/EventEmitter.js +66 -0
  45. package/src/lib/KnexHelper.js +30 -0
  46. package/src/lib/index.js +2 -0
  47. package/src/middleware/attachLogger.js +8 -0
  48. package/src/middleware/createTransaction.js +33 -0
  49. package/src/middleware/extendContext.js +10 -0
  50. package/src/middleware/findRoute.js +20 -0
  51. package/src/middleware/handleConnectMiddleware.js +99 -0
  52. package/src/middleware/handleError.js +29 -0
  53. package/src/middleware/handleRoute.js +23 -0
  54. package/src/middleware/handleSession.js +77 -0
  55. package/src/middleware/handleUser.js +31 -0
  56. package/src/middleware/index.js +11 -0
  57. package/src/middleware/logRequests.js +125 -0
  58. package/src/middleware/setupRequestStorage.js +14 -0
  59. package/src/mixins/AssetMixin.js +78 -0
  60. package/src/mixins/SessionMixin.js +17 -0
  61. package/src/mixins/TimeStampedMixin.js +41 -0
  62. package/src/mixins/UserMixin.js +171 -0
  63. package/src/mixins/index.js +4 -0
  64. package/src/models/AssetModel.js +4 -0
  65. package/src/models/Model.js +1205 -0
  66. package/src/models/RelationAccessor.js +41 -0
  67. package/src/models/SessionModel.js +4 -0
  68. package/src/models/TimeStampedModel.js +4 -0
  69. package/src/models/UserModel.js +4 -0
  70. package/src/models/definitions/assets.js +5 -0
  71. package/src/models/definitions/filters.js +121 -0
  72. package/src/models/definitions/hooks.js +8 -0
  73. package/src/models/definitions/index.js +22 -0
  74. package/src/models/definitions/modifiers.js +5 -0
  75. package/src/models/definitions/options.js +5 -0
  76. package/src/models/definitions/properties.js +73 -0
  77. package/src/models/definitions/relations.js +5 -0
  78. package/src/models/definitions/schema.js +5 -0
  79. package/src/models/definitions/scopes.js +36 -0
  80. package/src/models/index.js +5 -0
  81. package/src/query/QueryBuilder.js +1077 -0
  82. package/src/query/QueryFilters.js +66 -0
  83. package/src/query/QueryParameters.js +79 -0
  84. package/src/query/Registry.js +29 -0
  85. package/src/query/index.js +3 -0
  86. package/src/schema/formats/_empty.js +4 -0
  87. package/src/schema/formats/_required.js +4 -0
  88. package/src/schema/formats/index.js +2 -0
  89. package/src/schema/index.js +5 -0
  90. package/src/schema/keywords/_computed.js +7 -0
  91. package/src/schema/keywords/_foreign.js +7 -0
  92. package/src/schema/keywords/_hidden.js +7 -0
  93. package/src/schema/keywords/_index.js +7 -0
  94. package/src/schema/keywords/_instanceof.js +45 -0
  95. package/src/schema/keywords/_primary.js +7 -0
  96. package/src/schema/keywords/_range.js +18 -0
  97. package/src/schema/keywords/_relate.js +13 -0
  98. package/src/schema/keywords/_specificType.js +7 -0
  99. package/src/schema/keywords/_unique.js +7 -0
  100. package/src/schema/keywords/_unsigned.js +7 -0
  101. package/src/schema/keywords/_validate.js +73 -0
  102. package/src/schema/keywords/index.js +12 -0
  103. package/src/schema/relations.js +324 -0
  104. package/src/schema/relations.test.js +177 -0
  105. package/src/schema/schema.js +289 -0
  106. package/src/schema/schema.test.js +720 -0
  107. package/src/schema/types/_asset.js +31 -0
  108. package/src/schema/types/_color.js +4 -0
  109. package/src/schema/types/index.js +2 -0
  110. package/src/services/Service.js +35 -0
  111. package/src/services/index.js +1 -0
  112. package/src/storage/AssetFile.js +81 -0
  113. package/src/storage/DiskStorage.js +114 -0
  114. package/src/storage/S3Storage.js +169 -0
  115. package/src/storage/Storage.js +231 -0
  116. package/src/storage/index.js +9 -0
  117. package/src/utils/duration.js +15 -0
  118. package/src/utils/emitter.js +8 -0
  119. package/src/utils/fs.js +10 -0
  120. package/src/utils/function.js +17 -0
  121. package/src/utils/function.test.js +77 -0
  122. package/src/utils/handler.js +17 -0
  123. package/src/utils/json.js +3 -0
  124. package/src/utils/model.js +35 -0
  125. package/src/utils/net.js +17 -0
  126. package/src/utils/object.js +82 -0
  127. package/src/utils/object.test.js +86 -0
  128. package/src/utils/scope.js +7 -0
  129. package/types/index.d.ts +3547 -0
  130. package/types/tests/application.test-d.ts +26 -0
  131. package/types/tests/controller.test-d.ts +113 -0
  132. package/types/tests/errors.test-d.ts +53 -0
  133. package/types/tests/fixtures.ts +19 -0
  134. package/types/tests/model.test-d.ts +193 -0
  135. package/types/tests/query-builder.test-d.ts +106 -0
  136. package/types/tests/relation.test-d.ts +83 -0
  137. package/types/tests/storage.test-d.ts +113 -0
@@ -0,0 +1,1186 @@
1
+ import path from 'path'
2
+ import util from 'util'
3
+ import zlib from 'zlib'
4
+ import fs from 'fs/promises'
5
+ import Koa from 'koa'
6
+ import Knex from 'knex'
7
+ import pico from 'picocolors'
8
+ import pino from 'pino'
9
+ import bodyParser from 'koa-bodyparser'
10
+ import cors from '@koa/cors'
11
+ import etag from '@koa/etag'
12
+ import compose from 'koa-compose'
13
+ import compress from 'koa-compress'
14
+ import conditional from 'koa-conditional-get'
15
+ import mount from 'koa-mount'
16
+ import passport from 'koa-passport'
17
+ import helmet from 'koa-helmet'
18
+ import responseTime from 'koa-response-time'
19
+ import { Model, knexSnakeCaseMappers, ref } from 'objection'
20
+ import Router from '@ditojs/router'
21
+ import {
22
+ isArray,
23
+ isObject,
24
+ asArray,
25
+ isPlainObject,
26
+ isModule,
27
+ hyphenate,
28
+ clone,
29
+ groupBy,
30
+ assignDeeply,
31
+ parseDataPath,
32
+ normalizeDataPath,
33
+ toPromiseCallback,
34
+ mapConcurrently
35
+ } from '@ditojs/utils'
36
+ import { Validator } from './Validator.js'
37
+ import { EventEmitter } from '../lib/index.js'
38
+ import { Controller, AdminController } from '../controllers/index.js'
39
+ import { Service } from '../services/index.js'
40
+ import { Storage } from '../storage/index.js'
41
+ import { convertSchema } from '../schema/index.js'
42
+ import { getDuration, subtractDuration } from '../utils/duration.js'
43
+ import {
44
+ ResponseError,
45
+ ValidationError,
46
+ DatabaseError,
47
+ AssetError
48
+ } from '../errors/index.js'
49
+ import {
50
+ attachLogger,
51
+ createTransaction,
52
+ findRoute,
53
+ extendContext,
54
+ handleError,
55
+ handleRoute,
56
+ handleSession,
57
+ handleUser,
58
+ logRequests,
59
+ setupRequestStorage
60
+ } from '../middleware/index.js'
61
+ import { AsyncLocalStorage } from 'async_hooks'
62
+
63
+ export class Application extends Koa {
64
+ #logger
65
+
66
+ constructor({
67
+ basePath = process.cwd(),
68
+ config = {},
69
+ validator,
70
+ router,
71
+ events,
72
+ middleware,
73
+ services,
74
+ models,
75
+ controllers
76
+ } = {}) {
77
+ super()
78
+ this.basePath = basePath
79
+ this._configureEmitter(events)
80
+ const {
81
+ // Pluck keys out of `config.app` to keep them secret
82
+ app: { keys, ...app } = {},
83
+ log,
84
+ assets,
85
+ logger,
86
+ ...rest
87
+ } = config
88
+ this.config = {
89
+ app,
90
+ log:
91
+ log === false || log?.silent || process.env.DITO_SILENT
92
+ ? {}
93
+ : getOptions(log),
94
+ assets: assignDeeply(defaultAssetOptions, getOptions(assets)),
95
+ logger: assignDeeply(defaultLoggerOptions, getOptions(logger)),
96
+ ...rest
97
+ }
98
+ this.keys = keys
99
+ this.proxy = !!app.proxy
100
+ this.validator = validator || new Validator()
101
+ this.router = router || new Router()
102
+ this.validator.app = this
103
+ this.storages = Object.create(null)
104
+ this.services = Object.create(null)
105
+ this.models = Object.create(null)
106
+ this.controllers = Object.create(null)
107
+ this.server = null
108
+ this.isRunning = false
109
+ this.requestStorage = new AsyncLocalStorage()
110
+
111
+ // TODO: Rename setup to configure?
112
+ this.setupLogger()
113
+ this.setupKnex()
114
+ this.setupMiddleware(middleware)
115
+
116
+ if (config.storages) {
117
+ this.addStorages(config.storages)
118
+ }
119
+ if (services) {
120
+ this.addServices(services)
121
+ }
122
+ if (models) {
123
+ this.addModels(models)
124
+ }
125
+ if (controllers) {
126
+ this.addControllers(controllers)
127
+ }
128
+ }
129
+
130
+ async setup() {
131
+ await this.setupStorages()
132
+ await this.setupServices()
133
+ await this.setupModels()
134
+ await this.setupControllers()
135
+ }
136
+
137
+ addRoute(
138
+ method,
139
+ path,
140
+ transacted,
141
+ middlewares,
142
+ controller = null,
143
+ action = null
144
+ ) {
145
+ middlewares = asArray(middlewares)
146
+ const middleware =
147
+ middlewares.length > 1
148
+ ? compose(middlewares)
149
+ : middlewares[0]
150
+ // Instead of directly passing `handler`, pass a `route` object that also
151
+ // will be exposed through `ctx.route`, see `routerHandler()`:
152
+ const route = {
153
+ method,
154
+ path,
155
+ transacted,
156
+ middleware,
157
+ controller,
158
+ action
159
+ }
160
+ if (!(method in this.router)) {
161
+ throw new Error(
162
+ `Unsupported HTTP method '${method}' in route '${path}'`
163
+ )
164
+ }
165
+ this.router[method](path, route)
166
+ }
167
+
168
+ fixModuleClassNames(modules) {
169
+ // Naming fix for a weird vite 6 bug where the model classes are sometimes
170
+ // prefixed with `_`, sometimes suffixed with numbers, but only when
171
+ // imported through `vite.config.js`.
172
+ // NOTE: This only happens when `Application` is imported into
173
+ // `admin.vite.config.js` in order to call `app.defineAdminViteConfig()`
174
+ if (isPlainObject(modules)) {
175
+ for (const [key, module] of Object.entries(modules)) {
176
+ if (
177
+ module &&
178
+ module.name !== key &&
179
+ module.name?.replace(/^_|\d+$/g, '') === key
180
+ ) {
181
+ Object.defineProperty(module, 'name', {
182
+ value: key,
183
+ writable: false,
184
+ enumerable: false,
185
+ configurable: true
186
+ })
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ getStorage(name) {
193
+ return this.storages[name] || null
194
+ }
195
+
196
+ addStorage(config, name) {
197
+ let storage = null
198
+ if (isPlainObject(config)) {
199
+ const storageClass = Storage.get(config.type)
200
+ if (!storageClass) {
201
+ throw new Error(`Unsupported storage: ${config}`)
202
+ }
203
+ // eslint-disable-next-line new-cap
204
+ storage = new storageClass(this, config)
205
+ } else if (config instanceof Storage) {
206
+ storage = config
207
+ }
208
+ if (storage) {
209
+ if (name) {
210
+ storage.name = name
211
+ }
212
+ this.storages[storage.name] = storage
213
+ }
214
+ return storage
215
+ }
216
+
217
+ addStorages(storages) {
218
+ this.fixModuleClassNames(storages)
219
+ for (const [key, config] of Object.entries(storages)) {
220
+ this.addStorage(config, key)
221
+ }
222
+ }
223
+
224
+ async setupStorages() {
225
+ await Promise.all(
226
+ Object.values(this.storages)
227
+ .filter(storage => !storage.initialized)
228
+ .map(async storage => {
229
+ // Different from models, services and controllers, storages can have
230
+ // async `setup()` methods, as used by `S3Storage`.
231
+ await storage.setup()
232
+ await storage.initialize()
233
+ storage.initialized = true
234
+ })
235
+ )
236
+ }
237
+
238
+ getService(name) {
239
+ return this.services[name] || null
240
+ }
241
+
242
+ findService(callback) {
243
+ return Object.values(this.services).find(callback) || null
244
+ }
245
+
246
+ addService(service, name) {
247
+ // Auto-instantiate controller classes:
248
+ if (Service.isPrototypeOf(service)) {
249
+ // eslint-disable-next-line new-cap
250
+ service = new service(this, name)
251
+ }
252
+ if (!(service instanceof Service)) {
253
+ throw new Error(`Invalid service: ${service}`)
254
+ }
255
+ this.services[service.name] = service
256
+ }
257
+
258
+ addServices(services) {
259
+ this.fixModuleClassNames(services)
260
+ for (const [key, service] of Object.entries(services)) {
261
+ this.addService(service, key)
262
+ }
263
+ }
264
+
265
+ async setupServices() {
266
+ await Promise.all(
267
+ Object.values(this.services)
268
+ .filter(service => !service.initialized)
269
+ .map(async service => {
270
+ const { name } = service
271
+ const config = this.config.services[name]
272
+ if (config === undefined) {
273
+ throw new Error(`Configuration missing for service '${name}'`)
274
+ }
275
+ // As a convention, the configuration of a service can be set to
276
+ // `false` in order to entirely deactivate the service.
277
+ if (config === false) {
278
+ delete this.services[name]
279
+ } else {
280
+ service.setup(config)
281
+ await service.initialize()
282
+ service.initialized = true
283
+ }
284
+ })
285
+ )
286
+ }
287
+
288
+ getModel(name) {
289
+ return (
290
+ this.models[name] ||
291
+ !name.endsWith('Model') && this.models[`${name}Model`] ||
292
+ null
293
+ )
294
+ }
295
+
296
+ findModel(callback) {
297
+ return Object.values(this.models).find(callback) || null
298
+ }
299
+
300
+ addModel(modelClass) {
301
+ this.addModels([modelClass])
302
+ }
303
+
304
+ addModels(models) {
305
+ this.fixModuleClassNames(models)
306
+ models = Object.values(models)
307
+ // First, add all models to the application, so that they can be referenced
308
+ // by other models, e.g. in `jsonSchema` and `relationMappings`:
309
+ for (const modelClass of models) {
310
+ if (Model.isPrototypeOf(modelClass)) {
311
+ this.models[modelClass.name] = modelClass
312
+ } else {
313
+ throw new Error(`Invalid model class: ${modelClass}`)
314
+ }
315
+ }
316
+ // Then, configure all models and add their schemas to the validator:
317
+ for (const modelClass of models) {
318
+ modelClass.configure(this)
319
+ this.validator.addSchema(modelClass.getJsonSchema())
320
+ }
321
+ this.logModels(models)
322
+ }
323
+
324
+ async setupModels() {
325
+ await Promise.all(
326
+ Object.values(this.models)
327
+ .filter(modelClass => !modelClass.initialized)
328
+ .map(async modelClass => {
329
+ // While `setup()` is used for internal dito things, `initialize()` is
330
+ // called async and meant to be used by the user, without the need to
331
+ // call `super.initialize()`.
332
+ modelClass.setup()
333
+ await modelClass.initialize()
334
+ modelClass.initialized = true
335
+ })
336
+ )
337
+ }
338
+
339
+ logModels(models) {
340
+ const { log } = this.config
341
+ if (log.schema || log.relations) {
342
+ for (const modelClass of models) {
343
+ const shouldLog = option => (
344
+ option === true || asArray(option).includes(modelClass.name)
345
+ )
346
+ const data = {}
347
+ if (shouldLog(log.schema)) {
348
+ data.schema = modelClass.getJsonSchema()
349
+ }
350
+ if (shouldLog(log.relations)) {
351
+ data.relations = clone(modelClass.getRelationMappings(), {
352
+ processValue: value =>
353
+ Model.isPrototypeOf(value)
354
+ ? `[Model: ${value.name}]`
355
+ : value
356
+ })
357
+ }
358
+ if (Object.keys(data).length > 0) {
359
+ console.info(
360
+ pico.yellow(pico.bold(`\n${modelClass.name}:\n`)),
361
+ util.inspect(data, {
362
+ colors: true,
363
+ depth: null,
364
+ maxArrayLength: null
365
+ })
366
+ )
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ getController(url) {
373
+ return this.controllers[url] || null
374
+ }
375
+
376
+ findController(callback) {
377
+ return Object.values(this.controllers).find(callback) || null
378
+ }
379
+
380
+ addController(controller, namespace) {
381
+ // Auto-instantiate controller classes:
382
+ if (Controller.isPrototypeOf(controller)) {
383
+ // eslint-disable-next-line new-cap
384
+ controller = new controller(this, namespace)
385
+ }
386
+ if (!(controller instanceof Controller)) {
387
+ throw new Error(`Invalid controller: ${controller}`)
388
+ }
389
+ // Inheritance of action methods cannot happen in the constructor itself,
390
+ // so call separate `configure()` method after in order to take care of it.
391
+ controller.configure()
392
+ this.controllers[controller.url] = controller
393
+ }
394
+
395
+ addControllers(controllers, namespace) {
396
+ this.fixModuleClassNames(controllers)
397
+ for (const [key, value] of Object.entries(controllers)) {
398
+ if (isModule(value) || isPlainObject(value)) {
399
+ this.addControllers(value, namespace ? `${namespace}/${key}` : key)
400
+ } else {
401
+ this.addController(value, namespace)
402
+ }
403
+ }
404
+ }
405
+
406
+ async setupControllers() {
407
+ await Promise.all(
408
+ Object.values(this.controllers)
409
+ .filter(controller => !controller.initialized)
410
+ .map(async controller => {
411
+ controller.setup()
412
+ await controller.initialize()
413
+ // Each controller can also compose their own middleware (or app),
414
+ // e.g. as used in `AdminController`:
415
+ const composed = controller.compose()
416
+ if (composed) {
417
+ this.use(mount(controller.url, composed))
418
+ }
419
+ controller.initialized = true
420
+ })
421
+ )
422
+ }
423
+
424
+ getAdminController() {
425
+ return this.findController(
426
+ controller => controller instanceof AdminController
427
+ )
428
+ }
429
+
430
+ defineAdminViteConfig(config) {
431
+ return this.getAdminController()?.defineViteConfig(config) || null
432
+ }
433
+
434
+ async loadAdminViteConfig() {
435
+ for (const extension of ['js', 'mjs', 'cjs', 'ts']) {
436
+ const file = path.join(this.basePath, `admin.vite.config.${extension}`)
437
+ try {
438
+ await fs.access(file)
439
+ return (await import(file)).default
440
+ } catch (error) {
441
+ if (error.code !== 'ENOENT') {
442
+ throw error
443
+ }
444
+ }
445
+ }
446
+ return null
447
+ }
448
+
449
+ getAssetConfig({
450
+ models = Object.keys(this.models),
451
+ normalizeDbNames = this.config.knex.normalizeDbNames
452
+ } = {}) {
453
+ const assetConfig = {}
454
+ for (const modelName of models) {
455
+ const modelClass = this.models[modelName]
456
+ const { assets } = modelClass.definition
457
+ if (assets) {
458
+ const normalizedModelName = normalizeDbNames
459
+ ? this.normalizeIdentifier(modelName)
460
+ : modelName
461
+ const convertedAssets = {}
462
+ for (const [assetDataPath, config] of Object.entries(assets)) {
463
+ const {
464
+ property,
465
+ relation,
466
+ wildcard,
467
+ nestedDataPath,
468
+ name
469
+ } = modelClass.getPropertyOrRelationAtDataPath(assetDataPath)
470
+ if (relation) {
471
+ throw new Error('Assets on nested relations are not supported')
472
+ } else if (property || wildcard) {
473
+ const normalizedName = normalizeDbNames
474
+ ? this.normalizeIdentifier(name)
475
+ : name
476
+ const dataPath = normalizeDataPath([
477
+ wildcard || normalizedName,
478
+ ...parseDataPath(nestedDataPath)
479
+ ])
480
+ const assetConfigs = (convertedAssets[normalizedName] ||= {})
481
+ assetConfigs[dataPath] = config
482
+ }
483
+ }
484
+ assetConfig[normalizedModelName] = convertedAssets
485
+ }
486
+ }
487
+ return assetConfig
488
+ }
489
+
490
+ compileValidator(jsonSchema, options) {
491
+ return jsonSchema
492
+ ? this.validator.compile(jsonSchema, options)
493
+ : null
494
+ }
495
+
496
+ compileParametersValidator(parameters, options = {}) {
497
+ const list = []
498
+ const { dataName = 'data' } = options
499
+
500
+ let properties = null
501
+ const addParameter = (name, schema) => {
502
+ list.push({
503
+ name: name ?? null,
504
+ ...schema
505
+ })
506
+ if (!schema.member) {
507
+ properties ||= {}
508
+ properties[name || dataName] = schema
509
+ }
510
+ }
511
+
512
+ // Support two formats of parameters definitions:
513
+ // - An array of parameter schemas, named by their `name` key.
514
+ // - An object of parameter schemas, named by the key under which each
515
+ // schema is stored in the root object.
516
+ // If an array is passed, then the controller actions receives the
517
+ // parameters as separate arguments. If an object is passed, then the
518
+ // actions receives one parameter object where under the same keys the
519
+ // specified parameter values are stored.
520
+ let asObject = false
521
+ if (isArray(parameters)) {
522
+ for (const { name, ...schema } of parameters) {
523
+ addParameter(name, schema)
524
+ }
525
+ } else if (isObject(parameters)) {
526
+ asObject = true
527
+ for (const [name, schema] of Object.entries(parameters)) {
528
+ if (schema) {
529
+ addParameter(name, schema)
530
+ }
531
+ }
532
+ } else if (parameters) {
533
+ throw new Error(`Invalid parameters definition: ${parameters}`)
534
+ }
535
+ const schema = properties
536
+ ? convertSchema({ type: 'object', properties }, options)
537
+ : null
538
+
539
+ // Method to recursively check the compiled JSON schema and its sub-schemas
540
+ // to see if it has any `$ref` references to model schemas:
541
+ const hasModelRefs = schema => (
542
+ !!this.models[schema?.$ref] ||
543
+ (isArray(schema) || isPlainObject(schema)) &&
544
+ Object.values(schema).some(hasModelRefs)
545
+ )
546
+
547
+ const validate = this.compileValidator(schema, {
548
+ // For parameters, always coerce types, including arrays.
549
+ coerceTypes: 'array',
550
+ ...options
551
+ })
552
+ const ctx = {
553
+ app: this,
554
+ validator: this.validator,
555
+ options
556
+ }
557
+ return {
558
+ list,
559
+ schema,
560
+ asObject,
561
+ dataName,
562
+ validate: validate
563
+ ? // Use `call()` to pass ctx as context to Ajv, see passContext:
564
+ data => validate.call(ctx, data)
565
+ : null,
566
+ get hasModelRefs() {
567
+ return hasModelRefs(schema)
568
+ }
569
+ }
570
+ }
571
+
572
+ createValidationError({ type, message, errors, options, json }) {
573
+ return new ValidationError({
574
+ type,
575
+ message,
576
+ errors: this.validator.parseErrors(errors, options),
577
+ // Only include the JSON data in the error if `log.errors.json`is set.
578
+ json: this.config.log.errors?.json ? json : undefined
579
+ })
580
+ }
581
+
582
+ createDatabaseError(error) {
583
+ // Remove knex SQL query and move to separate `sql` property.
584
+ // TODO: Fix this properly in Knex / Objection instead, see:
585
+ // https://gitter.im/Vincit/objection.js?at=5a68728f5a9ebe4f75ca40b0
586
+ const [, sql, message] = (
587
+ error.message.match(/^([\s\S]*) - ([\s\S]*?)$/) ||
588
+ [null, null, error.message]
589
+ )
590
+ return new DatabaseError(error, {
591
+ message,
592
+ // Only include the SQL query in the error if `log.errors.sql`is set.
593
+ sql: this.config.log.errors?.sql ? sql : undefined
594
+ })
595
+ }
596
+
597
+ setupMiddleware(middleware) {
598
+ const { app, log } = this.config
599
+
600
+ // Setup global middleware
601
+
602
+ this.use(attachLogger(this.#logger))
603
+ if (app.responseTime !== false) {
604
+ this.use(responseTime(getOptions(app.responseTime)))
605
+ }
606
+ if (log.requests) {
607
+ this.use(
608
+ logRequests({
609
+ ignoreUrlPattern: /(\.js$|\.scss$|\.vue$|\/@vite\/|\/@fs\/|\/@id\/)/
610
+ })
611
+ )
612
+ }
613
+ // This needs to be positioned after the request logger to log the correct
614
+ // response status.
615
+ this.use(handleError())
616
+ this.use(extendContext())
617
+ if (app.helmet !== false) {
618
+ this.use(helmet(getOptions(app.helmet)))
619
+ }
620
+ if (app.cors !== false) {
621
+ this.use(cors(getOptions(app.cors)))
622
+ }
623
+ if (app.compress !== false) {
624
+ this.use(
625
+ compress(
626
+ assignDeeply(
627
+ {
628
+ // Use a reasonable default for Brotli compression.
629
+ // See https://github.com/koajs/compress/issues/126
630
+ br: {
631
+ params: {
632
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 4
633
+ }
634
+ }
635
+ },
636
+ getOptions(app.compress)
637
+ )
638
+ )
639
+ )
640
+ }
641
+ if (app.etag !== false) {
642
+ this.use(conditional())
643
+ this.use(etag())
644
+ }
645
+ this.use(setupRequestStorage(this.requestStorage))
646
+
647
+ // Controller-specific middleware
648
+
649
+ // 1. Find route from routes installed by controllers.
650
+ this.use(findRoute(this.router))
651
+ // 2. Additional, user-space application-level middleware.
652
+ if (middleware) {
653
+ this.use(middleware)
654
+ }
655
+ // 3. body parser
656
+ this.use(bodyParser(getOptions(app.bodyParser)))
657
+ // 4. respect transacted settings, create and handle transactions.
658
+ this.use(createTransaction())
659
+ // 5. session
660
+ if (app.session) {
661
+ this.use(handleSession(this, getOptions(app.session)))
662
+ }
663
+ // 6. passport
664
+ if (app.passport) {
665
+ this.use(passport.initialize())
666
+ if (app.session) {
667
+ this.use(passport.session())
668
+ }
669
+ this.use(handleUser())
670
+ }
671
+ // 6. finally handle the found route, or set status / allow accordingly.
672
+ this.use(handleRoute())
673
+ }
674
+
675
+ setupLogger() {
676
+ const { prettyPrint, ...options } = this.config.logger
677
+ const transport = prettyPrint
678
+ ? pino.transport({
679
+ target: 'pino-pretty',
680
+ options: prettyPrint
681
+ })
682
+ : null
683
+ this.#logger = pino(options, transport).child({ name: 'app' })
684
+ }
685
+
686
+ setupKnex() {
687
+ let { knex, log } = this.config
688
+ if (knex?.client) {
689
+ const snakeCaseOptions =
690
+ knex.normalizeDbNames === true
691
+ ? {}
692
+ : knex.normalizeDbNames
693
+ if (snakeCaseOptions) {
694
+ knex = {
695
+ ...knex,
696
+ ...knexSnakeCaseMappers(snakeCaseOptions)
697
+ }
698
+ }
699
+ this.knex = Knex(knex)
700
+ // Support PostgreSQL type parser mappings in the config.
701
+ if (
702
+ knex.client === 'postgresql' &&
703
+ knex.typeParsers &&
704
+ this.knex.client.driver
705
+ ) {
706
+ const { types } = this.knex.client.driver
707
+ // Support type parser mappings defined in user-land.
708
+ for (const [type, parser] of Object.entries(knex.typeParsers)) {
709
+ types.setTypeParser(type, parser)
710
+ }
711
+ // Automatically setup array type parsers for numeric and int8.
712
+ const setupArrayParser = (valueType, arrayType) => {
713
+ if (
714
+ valueType in knex.typeParsers &&
715
+ !(arrayType in knex.typeParsers)
716
+ ) {
717
+ const parseValue = types.getTypeParser(valueType)
718
+ const parseArray = types.getTypeParser(arrayType)
719
+ types.setTypeParser(arrayType, text =>
720
+ parseArray(text).map(value =>
721
+ value === null ? value : parseValue(value)
722
+ )
723
+ )
724
+ }
725
+ }
726
+
727
+ setupArrayParser(1700, 1231) // numeric
728
+ setupArrayParser(20, 1016) // int8
729
+ // setupArrayParser(21, 1005) // int2
730
+ // setupArrayParser(23, 1007) // int4
731
+ // setupArrayParser(700, 1021) // float4
732
+ // setupArrayParser(701, 1022) // float8
733
+ }
734
+ if (log.sql) {
735
+ this.setupKnexLogging()
736
+ }
737
+ }
738
+ }
739
+
740
+ setupKnexLogging() {
741
+ const startTimes = {}
742
+ const logger = this.logger.child({ name: 'sql' })
743
+ function end(query, { response, error }) {
744
+ const id = query.__knexQueryUid
745
+ const diff = process.hrtime(startTimes[id])
746
+ const duration = diff[0] * 1e3 + diff[1] / 1e6
747
+ delete startTimes[id]
748
+ const { sql, bindings } = query
749
+ response = response
750
+ ? Object.fromEntries(
751
+ Object.entries(response).filter(
752
+ ([key]) => !key.startsWith('_')
753
+ )
754
+ )
755
+ : null
756
+ logger.info({ duration, bindings, response, error }, sql)
757
+ }
758
+
759
+ this.knex
760
+ .on('query', query => {
761
+ startTimes[query.__knexQueryUid] = process.hrtime()
762
+ })
763
+ .on('query-response', (response, query) => {
764
+ end(query, { response })
765
+ })
766
+ .on('query-error', (error, query) => {
767
+ end(query, { error })
768
+ })
769
+ }
770
+
771
+ normalizeIdentifier(identifier) {
772
+ return this.knex.client.wrapIdentifier(identifier).replace(/['`"]/g, '')
773
+ }
774
+
775
+ denormalizeIdentifier(identifier) {
776
+ const obj = this.knex.client.postProcessResponse({ [identifier]: 1 })
777
+ return Object.keys(obj)[0]
778
+ }
779
+
780
+ normalizePath(path) {
781
+ return this.config.app.normalizePaths ? hyphenate(path) : path
782
+ }
783
+
784
+ formatError(error) {
785
+ // Shallow-clone the error to be able to delete hidden properties.
786
+ const copy = clone(error, { shallow: true, enumerable: false })
787
+ // Remove headers added by the CORS middleware.
788
+ delete copy.headers
789
+ if (this.config.log.errors?.stack === false) {
790
+ delete copy.stack
791
+ delete copy.cause
792
+ } else {
793
+ // Explicitly copy the stack trace, as clone() might not copy it.
794
+ copy.stack = error.stack
795
+ }
796
+ // Use `util.inspect()` instead of Pino's internal error logging for better
797
+ // stack traces and logging of error data.
798
+ return this.config.logger.prettyPrint
799
+ ? util.inspect(copy, {
800
+ colors: !!this.config.logger.prettyPrint.colorize,
801
+ compact: false,
802
+ depth: null,
803
+ maxArrayLength: null
804
+ })
805
+ : copy
806
+ }
807
+
808
+ logError(error, ctx) {
809
+ if (!error.expose && !this.silent) {
810
+ try {
811
+ const logger = ctx?.logger || this.logger
812
+ const level =
813
+ error instanceof ResponseError && error.status < 500
814
+ ? 'info'
815
+ : 'error'
816
+ logger[level](this.formatError(error))
817
+ } catch (e) {
818
+ console.error('Could not log error', e)
819
+ }
820
+ }
821
+ }
822
+
823
+ async start() {
824
+ if (this.config.log.errors !== false) {
825
+ this.on('error', this.logError)
826
+ }
827
+ // It's ok to call this multiple times, because only the entries in the
828
+ // registers (storages, services, models, controllers) that weren't
829
+ // initialized yet will be initialized.
830
+ await this.setup()
831
+ await this.emit('before:start')
832
+ this.server = await new Promise(resolve => {
833
+ const server = this.listen(this.config.server, () => {
834
+ const { address, port } = server.address()
835
+ console.info(
836
+ `Dito.js server started at http://${address}:${port}`
837
+ )
838
+ resolve(server)
839
+ })
840
+ })
841
+ if (!this.server) {
842
+ throw new Error('Unable to start Dito.js server')
843
+ }
844
+ this.isRunning = true
845
+ await this.emit('after:start')
846
+ }
847
+
848
+ async stop(timeout = 0) {
849
+ if (!this.server) {
850
+ throw new Error('Dito.js server is not running')
851
+ }
852
+
853
+ const promise = (async () => {
854
+ await this.emit('before:stop')
855
+ this.isRunning = false
856
+ await new Promise((resolve, reject) => {
857
+ this.server.close(toPromiseCallback(resolve, reject))
858
+ })
859
+ // Hack to make sure that the server is closed, even if sockets are still
860
+ // open after `server.close()`, see: https://stackoverflow.com/a/36830072
861
+ this.server.emit('close')
862
+ this.server = null
863
+ await this.emit('after:stop')
864
+ })()
865
+
866
+ if (timeout > 0) {
867
+ await Promise.race([
868
+ promise,
869
+ new Promise((resolve, reject) =>
870
+ setTimeout(
871
+ reject,
872
+ timeout,
873
+ new Error(
874
+ `Timeout reached while stopping Dito.js server (${timeout}ms)`
875
+ )
876
+ )
877
+ )
878
+ ])
879
+ } else {
880
+ await promise
881
+ }
882
+
883
+ if (this.config.log.errors !== false) {
884
+ this.off('error', this.logError)
885
+ }
886
+ }
887
+
888
+ async execute() {
889
+ try {
890
+ await this.start()
891
+ } catch (err) {
892
+ this.logError(err)
893
+ process.exit(-1)
894
+ }
895
+ }
896
+
897
+ // Assets handling
898
+
899
+ async createAssets(storage, files, count = 0, transaction = null) {
900
+ const AssetModel = this.getModel('Asset')
901
+ if (AssetModel) {
902
+ const assets = files.map(file => ({
903
+ key: file.key,
904
+ file,
905
+ storage: storage.name,
906
+ count
907
+ }))
908
+ return AssetModel.query(transaction).insert(assets)
909
+ }
910
+ return null
911
+ }
912
+
913
+ async handleAddedAndRemovedAssets(
914
+ storage,
915
+ addedFiles,
916
+ removedFiles,
917
+ changedFiles,
918
+ transaction = null
919
+ ) {
920
+ let importedFiles = []
921
+ const AssetModel = this.getModel('Asset')
922
+ if (AssetModel) {
923
+ importedFiles = await this.addForeignAssets(
924
+ storage,
925
+ [...addedFiles, ...changedFiles],
926
+ transaction
927
+ )
928
+ if (
929
+ addedFiles.length > 0 ||
930
+ removedFiles.length > 0
931
+ ) {
932
+ const changeCount = async (files, increment) => {
933
+ if (files.length > 0) {
934
+ await AssetModel.query(transaction)
935
+ .whereIn(
936
+ 'key',
937
+ files.map(file => file.key)
938
+ )
939
+ .increment('count', increment)
940
+ }
941
+ }
942
+ await Promise.all([
943
+ changeCount(addedFiles, 1),
944
+ changeCount(removedFiles, -1)
945
+ ])
946
+ const cleanupTimeThreshold = getDuration(
947
+ this.config.assets.cleanupTimeThreshold
948
+ )
949
+ if (cleanupTimeThreshold > 0) {
950
+ setTimeout(
951
+ // Don't pass `transaction` here, as we want this delayed execution
952
+ // to create its own transaction.
953
+ () => this.releaseUnusedAssets(),
954
+ cleanupTimeThreshold
955
+ )
956
+ }
957
+ }
958
+ // Also execute releaseUnusedAssets() immediately in the same
959
+ // transaction, to potentially clean up other pending assets.
960
+ await this.releaseUnusedAssets({ transaction })
961
+ return importedFiles
962
+ }
963
+ }
964
+
965
+ async addForeignAssets(storage, files, transaction = null) {
966
+ const importedFiles = []
967
+ const AssetModel = this.getModel('Asset')
968
+ if (AssetModel) {
969
+ // Find missing assets (copied from another system), and add them.
970
+ const filesByKey = groupBy(files, file => file.key)
971
+ await mapConcurrently(
972
+ Object.entries(filesByKey),
973
+ async ([key, files]) => {
974
+ const asset = await AssetModel.query(transaction).findOne('key', key)
975
+ if (!asset) {
976
+ const [file] = files // Pick the first file
977
+ if (file.data || file.url) {
978
+ let { data } = file
979
+ if (!data) {
980
+ const { url } = file
981
+ if (!storage.isImportSourceAllowed(url)) {
982
+ throw new AssetError(
983
+ `Unable to import asset from foreign source: '${
984
+ file.name
985
+ }' ('${
986
+ url
987
+ }'): The source needs to be explicitly allowed.`
988
+ )
989
+ }
990
+ this.logger.info(
991
+ `Asset ${
992
+ pico.green(`'${file.name}'`)
993
+ } is from a foreign source, fetching from ${
994
+ pico.green(`'${url}'`)
995
+ } and adding to storage ${
996
+ pico.green(`'${storage.name}'`)
997
+ }...`
998
+ )
999
+ if (url.startsWith('file://')) {
1000
+ const filepath = path.resolve(url.substring(7))
1001
+ data = await fs.readFile(filepath)
1002
+ } else {
1003
+ const response = await fetch(url)
1004
+ const arrayBuffer = await response.arrayBuffer()
1005
+ // `fs.writeFile()` expects a Buffer, not an ArrayBuffer.
1006
+ data = Buffer.from(arrayBuffer)
1007
+ }
1008
+ }
1009
+ const importedFile = await storage.addFile(file, data)
1010
+ await this.createAssets(storage, [importedFile], 0, transaction)
1011
+ importedFiles.push(importedFile)
1012
+ // Merge back the changed file properties into the actual file
1013
+ // objects, so that the data from the static model hook can be
1014
+ // used directly for the actual running query.
1015
+ for (const file of files) {
1016
+ Object.assign(file, importedFile)
1017
+ }
1018
+ } else {
1019
+ throw new AssetError(
1020
+ `Unable to import asset from foreign source: '${
1021
+ file.name
1022
+ }' ('${
1023
+ file.key
1024
+ }')`
1025
+ )
1026
+ }
1027
+ } else {
1028
+ // Asset is from a foreign source, but was already imported and can
1029
+ // be reused. See above for an explanation of this merge.
1030
+ for (const file of files) {
1031
+ Object.assign(file, asset.file)
1032
+ }
1033
+ // NOTE: No need to add `file` to `importedFiles`, since it's
1034
+ // already been imported to the storage before.
1035
+ }
1036
+ },
1037
+ { concurrency: storage.concurrency }
1038
+ )
1039
+ }
1040
+ return importedFiles
1041
+ }
1042
+
1043
+ async handleModifiedAssets(storage, files, transaction = null) {
1044
+ const modifiedFiles = []
1045
+ const AssetModel = this.getModel('Asset')
1046
+ if (AssetModel) {
1047
+ await mapConcurrently(
1048
+ files,
1049
+ async file => {
1050
+ if (file.data) {
1051
+ const asset = await AssetModel.query(transaction).findOne(
1052
+ 'key',
1053
+ file.key
1054
+ )
1055
+ if (asset) {
1056
+ const changedFile = await storage.addFile(file, file.data)
1057
+ // Merge back the changed file properties into the actual files
1058
+ // object, so that the data from the static model hook can be used
1059
+ // directly for the actual running query.
1060
+ Object.assign(file, changedFile)
1061
+ modifiedFiles.push(changedFile)
1062
+ } else {
1063
+ throw new AssetError(
1064
+ `Unable to update modified asset from memory source: '${
1065
+ file.name
1066
+ }' ('${
1067
+ file.key
1068
+ }')`
1069
+ )
1070
+ }
1071
+ }
1072
+ },
1073
+ { concurrency: storage.concurrency }
1074
+ )
1075
+ }
1076
+ return modifiedFiles
1077
+ }
1078
+
1079
+ async releaseUnusedAssets({
1080
+ timeThreshold = null,
1081
+ transaction = null,
1082
+ concurrency = 8
1083
+ } = {}) {
1084
+ const AssetModel = this.getModel('Asset')
1085
+ if (AssetModel) {
1086
+ const { assets } = this.config
1087
+ const cleanupTimeThreshold = getDuration(
1088
+ timeThreshold ?? assets.cleanupTimeThreshold
1089
+ )
1090
+ const danglingTimeThreshold = getDuration(
1091
+ timeThreshold ?? assets.danglingTimeThreshold
1092
+ )
1093
+ return AssetModel.transaction(transaction, async trx => {
1094
+ // Calculate the date math in JS instead of SQL, as there is no easy
1095
+ // cross-SQL way to do `now() - interval X hours`:
1096
+ const now = new Date()
1097
+ const cleanupDate = subtractDuration(now, cleanupTimeThreshold)
1098
+ const danglingDate = subtractDuration(now, danglingTimeThreshold)
1099
+ const orphanedAssets = await AssetModel.query(trx)
1100
+ .where('count', 0)
1101
+ .andWhere(query =>
1102
+ query
1103
+ .where('updatedAt', '<=', cleanupDate)
1104
+ .orWhere(
1105
+ // Protect freshly created assets from being deleted again
1106
+ // right away, when `config.assets.cleanupTimeThreshold = 0`
1107
+ query =>
1108
+ query
1109
+ .where('updatedAt', '=', ref('createdAt'))
1110
+ .andWhere('updatedAt', '<=', danglingDate)
1111
+ )
1112
+ )
1113
+ if (orphanedAssets.length > 0) {
1114
+ const orphanedKeys = await mapConcurrently(
1115
+ orphanedAssets,
1116
+ async asset => {
1117
+ try {
1118
+ await this.getStorage(asset.storage).removeFile(asset.file)
1119
+ } catch (error) {
1120
+ this.emit('error', error)
1121
+ asset.error = error
1122
+ }
1123
+ return asset.key
1124
+ },
1125
+ { concurrency }
1126
+ )
1127
+ await AssetModel.query(trx).delete().whereIn('key', orphanedKeys)
1128
+ }
1129
+ return orphanedAssets
1130
+ })
1131
+ }
1132
+ }
1133
+
1134
+ get requestLocals() {
1135
+ return this.requestStorage.getStore() ?? {}
1136
+ }
1137
+
1138
+ get logger() {
1139
+ return this.requestLocals.logger ?? this.#logger
1140
+ }
1141
+ }
1142
+
1143
+ // Override Koa's events with our own EventEmitter that adds support for
1144
+ // asynchronous events.
1145
+ EventEmitter.mixin(Application.prototype)
1146
+
1147
+ function getOptions(options) {
1148
+ return isObject(options) ? options : {}
1149
+ }
1150
+
1151
+ const defaultAssetOptions = {
1152
+ // Only remove unused or dangling assets that haven't seen changes for
1153
+ // these given time frames. Set to `0` to clean up instantly.
1154
+ cleanupTimeThreshold: '24h',
1155
+ // Dangling assets are those that got uploaded but never actually persisted in
1156
+ // the model. This can happen when the admin uploads a file but doesn't store
1157
+ // the associated form. This cannot be set to 0 or else the the file would be
1158
+ // deleted immediately after upload.
1159
+ danglingTimeThreshold: '24h'
1160
+ }
1161
+
1162
+ const { err, req, res } = pino.stdSerializers
1163
+ const defaultLoggerOptions = {
1164
+ level: 'info',
1165
+ serializers: {
1166
+ err,
1167
+ req,
1168
+ res,
1169
+ // Only include `id` from the user, to not inadvertently log PII.
1170
+ user: user => ({ id: user.id })
1171
+ },
1172
+ prettyPrint: {
1173
+ colorize: true,
1174
+ // List of keys to ignore in pretty mode.
1175
+ ignore: 'req,res,durationMs,user,requestId',
1176
+ // SYS to use system time and not UTC.
1177
+ translateTime: 'SYS:HH:MM:ss.l'
1178
+ },
1179
+ // Redact common sensitive headers.
1180
+ redact: [
1181
+ '*.headers["cookie"]',
1182
+ '*.headers["set-cookie"]',
1183
+ '*.headers["authorization"]'
1184
+ ],
1185
+ base: null // no pid,hostname,name
1186
+ }