@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.
- package/README.md +6 -0
- package/package.json +95 -0
- package/src/app/Application.js +1186 -0
- package/src/app/Validator.js +405 -0
- package/src/app/index.js +2 -0
- package/src/cli/console.js +152 -0
- package/src/cli/db/createMigration.js +241 -0
- package/src/cli/db/index.js +7 -0
- package/src/cli/db/listAssetConfig.js +10 -0
- package/src/cli/db/migrate.js +12 -0
- package/src/cli/db/reset.js +23 -0
- package/src/cli/db/rollback.js +12 -0
- package/src/cli/db/seed.js +80 -0
- package/src/cli/db/unlock.js +9 -0
- package/src/cli/index.js +72 -0
- package/src/controllers/AdminController.js +322 -0
- package/src/controllers/CollectionController.js +274 -0
- package/src/controllers/Controller.js +657 -0
- package/src/controllers/ControllerAction.js +370 -0
- package/src/controllers/MemberAction.js +27 -0
- package/src/controllers/ModelController.js +63 -0
- package/src/controllers/RelationController.js +93 -0
- package/src/controllers/UsersController.js +64 -0
- package/src/controllers/index.js +5 -0
- package/src/errors/AssetError.js +7 -0
- package/src/errors/AuthenticationError.js +7 -0
- package/src/errors/AuthorizationError.js +7 -0
- package/src/errors/ControllerError.js +14 -0
- package/src/errors/DatabaseError.js +37 -0
- package/src/errors/GraphError.js +7 -0
- package/src/errors/ModelError.js +12 -0
- package/src/errors/NotFoundError.js +7 -0
- package/src/errors/NotImplementedError.js +7 -0
- package/src/errors/QueryBuilderError.js +7 -0
- package/src/errors/RelationError.js +21 -0
- package/src/errors/ResponseError.js +56 -0
- package/src/errors/ValidationError.js +7 -0
- package/src/errors/index.js +13 -0
- package/src/graph/DitoGraphProcessor.js +213 -0
- package/src/graph/expression.js +53 -0
- package/src/graph/graph.js +258 -0
- package/src/graph/index.js +3 -0
- package/src/index.js +9 -0
- package/src/lib/EventEmitter.js +66 -0
- package/src/lib/KnexHelper.js +30 -0
- package/src/lib/index.js +2 -0
- package/src/middleware/attachLogger.js +8 -0
- package/src/middleware/createTransaction.js +33 -0
- package/src/middleware/extendContext.js +10 -0
- package/src/middleware/findRoute.js +20 -0
- package/src/middleware/handleConnectMiddleware.js +99 -0
- package/src/middleware/handleError.js +29 -0
- package/src/middleware/handleRoute.js +23 -0
- package/src/middleware/handleSession.js +77 -0
- package/src/middleware/handleUser.js +31 -0
- package/src/middleware/index.js +11 -0
- package/src/middleware/logRequests.js +125 -0
- package/src/middleware/setupRequestStorage.js +14 -0
- package/src/mixins/AssetMixin.js +78 -0
- package/src/mixins/SessionMixin.js +17 -0
- package/src/mixins/TimeStampedMixin.js +41 -0
- package/src/mixins/UserMixin.js +171 -0
- package/src/mixins/index.js +4 -0
- package/src/models/AssetModel.js +4 -0
- package/src/models/Model.js +1205 -0
- package/src/models/RelationAccessor.js +41 -0
- package/src/models/SessionModel.js +4 -0
- package/src/models/TimeStampedModel.js +4 -0
- package/src/models/UserModel.js +4 -0
- package/src/models/definitions/assets.js +5 -0
- package/src/models/definitions/filters.js +121 -0
- package/src/models/definitions/hooks.js +8 -0
- package/src/models/definitions/index.js +22 -0
- package/src/models/definitions/modifiers.js +5 -0
- package/src/models/definitions/options.js +5 -0
- package/src/models/definitions/properties.js +73 -0
- package/src/models/definitions/relations.js +5 -0
- package/src/models/definitions/schema.js +5 -0
- package/src/models/definitions/scopes.js +36 -0
- package/src/models/index.js +5 -0
- package/src/query/QueryBuilder.js +1077 -0
- package/src/query/QueryFilters.js +66 -0
- package/src/query/QueryParameters.js +79 -0
- package/src/query/Registry.js +29 -0
- package/src/query/index.js +3 -0
- package/src/schema/formats/_empty.js +4 -0
- package/src/schema/formats/_required.js +4 -0
- package/src/schema/formats/index.js +2 -0
- package/src/schema/index.js +5 -0
- package/src/schema/keywords/_computed.js +7 -0
- package/src/schema/keywords/_foreign.js +7 -0
- package/src/schema/keywords/_hidden.js +7 -0
- package/src/schema/keywords/_index.js +7 -0
- package/src/schema/keywords/_instanceof.js +45 -0
- package/src/schema/keywords/_primary.js +7 -0
- package/src/schema/keywords/_range.js +18 -0
- package/src/schema/keywords/_relate.js +13 -0
- package/src/schema/keywords/_specificType.js +7 -0
- package/src/schema/keywords/_unique.js +7 -0
- package/src/schema/keywords/_unsigned.js +7 -0
- package/src/schema/keywords/_validate.js +73 -0
- package/src/schema/keywords/index.js +12 -0
- package/src/schema/relations.js +324 -0
- package/src/schema/relations.test.js +177 -0
- package/src/schema/schema.js +289 -0
- package/src/schema/schema.test.js +720 -0
- package/src/schema/types/_asset.js +31 -0
- package/src/schema/types/_color.js +4 -0
- package/src/schema/types/index.js +2 -0
- package/src/services/Service.js +35 -0
- package/src/services/index.js +1 -0
- package/src/storage/AssetFile.js +81 -0
- package/src/storage/DiskStorage.js +114 -0
- package/src/storage/S3Storage.js +169 -0
- package/src/storage/Storage.js +231 -0
- package/src/storage/index.js +9 -0
- package/src/utils/duration.js +15 -0
- package/src/utils/emitter.js +8 -0
- package/src/utils/fs.js +10 -0
- package/src/utils/function.js +17 -0
- package/src/utils/function.test.js +77 -0
- package/src/utils/handler.js +17 -0
- package/src/utils/json.js +3 -0
- package/src/utils/model.js +35 -0
- package/src/utils/net.js +17 -0
- package/src/utils/object.js +82 -0
- package/src/utils/object.test.js +86 -0
- package/src/utils/scope.js +7 -0
- package/types/index.d.ts +3547 -0
- package/types/tests/application.test-d.ts +26 -0
- package/types/tests/controller.test-d.ts +113 -0
- package/types/tests/errors.test-d.ts +53 -0
- package/types/tests/fixtures.ts +19 -0
- package/types/tests/model.test-d.ts +193 -0
- package/types/tests/query-builder.test-d.ts +106 -0
- package/types/tests/relation.test-d.ts +83 -0
- 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
|
+
}
|