@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,31 @@
1
+ export function handleUser() {
2
+ return (ctx, next) => {
3
+ // Attach a logger instance with the user to the context.
4
+ ctx.logger = ctx.logger?.child({ user: ctx.state.user }) || null
5
+
6
+ // Override `ctx.login()` and `ctx.logout()` with versions that emit events
7
+ // on the user model:
8
+ const { login, logout } = ctx
9
+
10
+ ctx.login = ctx.logIn = async function (user, options = {}) {
11
+ await user.$emit('before:login', options)
12
+ await login.call(this, user, options)
13
+ await user.$emit('after:login', options)
14
+ }
15
+
16
+ ctx.logout = ctx.logOut = async function (options = {}) {
17
+ const { user } = ctx.state
18
+ await user?.$emit('before:logout', options)
19
+ await logout.call(this, options)
20
+ // Clear the session after logout, apparently koa-passport doesn't take
21
+ // care of this itself:
22
+ // https://stackoverflow.com/questions/55818887/koa-passport-logout-is-not-clearing-session
23
+ ctx.session = null
24
+ await user?.$emit('after:logout', options)
25
+ }
26
+
27
+ return next()
28
+ // Don't set back `ctx.logger` because the context is still valid. It is
29
+ // used in `logResponse()` of the `logRequests()` middleware, among others.
30
+ }
31
+ }
@@ -0,0 +1,11 @@
1
+ export * from './attachLogger.js'
2
+ export * from './createTransaction.js'
3
+ export * from './extendContext.js'
4
+ export * from './findRoute.js'
5
+ export * from './handleConnectMiddleware.js'
6
+ export * from './handleError.js'
7
+ export * from './handleRoute.js'
8
+ export * from './handleSession.js'
9
+ export * from './handleUser.js'
10
+ export * from './logRequests.js'
11
+ export * from './setupRequestStorage.js'
@@ -0,0 +1,125 @@
1
+ /*
2
+ * Middleware inspired by 'koa-logger'. Adapted and extended to our needs.
3
+ */
4
+ import bytes from 'bytes'
5
+ import pico from 'picocolors'
6
+ import Counter from 'passthrough-counter'
7
+
8
+ export function logRequests({ ignoreUrlPattern } = {}) {
9
+ return async (ctx, next) => {
10
+ if (ignoreUrlPattern && ctx.req.url.match(ignoreUrlPattern)) {
11
+ return next()
12
+ }
13
+ // request
14
+ const start = performance.now()
15
+
16
+ logRequest(ctx)
17
+
18
+ try {
19
+ await next()
20
+ } catch (err) {
21
+ logResponse({ ctx, start, err })
22
+ throw err
23
+ }
24
+
25
+ // Calculate the length of a streaming response by intercepting the stream
26
+ // with a counter. Only necessary if a content-length header is currently
27
+ // not set.
28
+ const {
29
+ body,
30
+ response: { length }
31
+ } = ctx
32
+
33
+ let counter
34
+ if (length === null && body?.readable) {
35
+ ctx.body = body.pipe((counter = new Counter())).on('error', ctx.onerror)
36
+ }
37
+
38
+ // Log when the response is finished or closed, whichever happens first.
39
+ const { res } = ctx
40
+
41
+ const onfinish = done.bind(null, 'finish')
42
+ const onclose = done.bind(null, 'close')
43
+
44
+ res.once('finish', onfinish)
45
+ res.once('close', onclose)
46
+
47
+ function done() {
48
+ res.removeListener('finish', onfinish)
49
+ res.removeListener('close', onclose)
50
+ logResponse({ ctx, start, length: counter ? counter.length : length })
51
+ }
52
+ }
53
+ }
54
+
55
+ function logRequest(ctx) {
56
+ const logger = ctx.logger?.child({ name: 'http' })
57
+ if (logger?.isLevelEnabled('trace')) {
58
+ logger.trace(
59
+ { req: ctx.req },
60
+ `${
61
+ pico.gray('<--')
62
+ } ${
63
+ pico.bold(ctx.method)
64
+ } ${
65
+ pico.gray(ctx.originalUrl)
66
+ }`
67
+ )
68
+ }
69
+ }
70
+
71
+ function logResponse({ ctx, start, length, err }) {
72
+ const logger = ctx.logger?.child({ name: 'http' })
73
+ const level = err ? 'warn' : 'info'
74
+ if (logger?.isLevelEnabled(level)) {
75
+ // Get the status code of the response
76
+ const status = err
77
+ ? err.status || 500
78
+ : ctx.status || 404
79
+
80
+ // Set the color of the status code;
81
+ const statusRange = (status / 100) | 0
82
+ const statusColor = colorCodes[statusRange] || colorCodes[0]
83
+
84
+ // Get the human readable response length
85
+ const formattedLength = [204, 205, 304].includes(status)
86
+ ? ''
87
+ : length == null
88
+ ? '-'
89
+ : bytes(length).toLowerCase()
90
+
91
+ const formattedTime = formatTime(start)
92
+
93
+ logger[level](
94
+ { req: ctx.req, res: ctx.res },
95
+ `${
96
+ pico.bold(ctx.method)
97
+ } ${
98
+ pico.gray(ctx.originalUrl)
99
+ } ${
100
+ pico[statusColor](status)
101
+ } ${
102
+ pico.gray(formattedTime)
103
+ } ${
104
+ pico.gray(formattedLength)
105
+ }`
106
+ )
107
+ }
108
+ }
109
+
110
+ function formatTime(start) {
111
+ const delta = performance.now() - start
112
+ return delta < 10000
113
+ ? +delta.toFixed(2) + 'ms'
114
+ : +(delta / 1000).toFixed(2) + 's'
115
+ }
116
+
117
+ const colorCodes = {
118
+ 7: 'magenta',
119
+ 5: 'red',
120
+ 4: 'yellow',
121
+ 3: 'cyan',
122
+ 2: 'green',
123
+ 1: 'green',
124
+ 0: 'yellow'
125
+ }
@@ -0,0 +1,14 @@
1
+ export function setupRequestStorage(requestStorage) {
2
+ return (ctx, next) =>
3
+ requestStorage.run(
4
+ {
5
+ get transaction() {
6
+ return ctx.transaction
7
+ },
8
+ get logger() {
9
+ return ctx.logger
10
+ }
11
+ },
12
+ next
13
+ )
14
+ }
@@ -0,0 +1,78 @@
1
+ import { mixin } from '@ditojs/utils'
2
+ import { TimeStampedMixin } from './TimeStampedMixin.js'
3
+
4
+ // Asset models are always to be time-stamped:
5
+ export const AssetMixin = mixin(
6
+ Model =>
7
+ class extends TimeStampedMixin(Model) {
8
+ static properties = {
9
+ key: {
10
+ type: 'string',
11
+ required: true,
12
+ unique: true,
13
+ index: true
14
+ },
15
+
16
+ file: {
17
+ type: 'object',
18
+ // TODO: Support this on 'object':
19
+ // required: true
20
+ properties: {
21
+ // The unique key within the storage (uuid/v4 + file extension)
22
+ key: {
23
+ type: 'string',
24
+ required: true
25
+ },
26
+ // The original filename, and display name when file is shown
27
+ name: {
28
+ type: 'string',
29
+ required: true
30
+ },
31
+ // The file's mime-type
32
+ type: {
33
+ type: 'string',
34
+ required: true
35
+ },
36
+ // The amount of bytes consumed by the file
37
+ size: {
38
+ type: 'integer',
39
+ required: true
40
+ },
41
+ // Use for storages configured for files to be publicly accessible:
42
+ url: {
43
+ type: 'string'
44
+ },
45
+ // These are only used when the storage defines
46
+ // `config.readDimensions`:
47
+ width: {
48
+ type: 'integer'
49
+ },
50
+ height: {
51
+ type: 'integer'
52
+ }
53
+ }
54
+ },
55
+
56
+ storage: {
57
+ type: 'string',
58
+ required: true
59
+ },
60
+
61
+ count: {
62
+ type: 'integer',
63
+ unsigned: true,
64
+ default: 0
65
+ }
66
+ }
67
+
68
+ // @override
69
+ $parseJson(json) {
70
+ const { file, storage } = json
71
+ // Convert `AssetMixin#file` to an `AssetFile` instance:
72
+ if (file && storage) {
73
+ this.constructor.app.getStorage(storage)?.convertAssetFile(file)
74
+ }
75
+ return json
76
+ }
77
+ }
78
+ )
@@ -0,0 +1,17 @@
1
+ import { mixin } from '@ditojs/utils'
2
+
3
+ export const SessionMixin = mixin(
4
+ Model =>
5
+ class extends Model {
6
+ static properties = {
7
+ id: {
8
+ type: 'string',
9
+ primary: true
10
+ },
11
+
12
+ value: {
13
+ type: 'object'
14
+ }
15
+ }
16
+ }
17
+ )
@@ -0,0 +1,41 @@
1
+ import { mixin } from '@ditojs/utils'
2
+
3
+ export const TimeStampedMixin = mixin(
4
+ Model =>
5
+ class extends Model {
6
+ static properties = {
7
+ createdAt: {
8
+ type: 'timestamp',
9
+ default: 'now()'
10
+ },
11
+
12
+ updatedAt: {
13
+ type: 'timestamp',
14
+ default: 'now()'
15
+ }
16
+ }
17
+
18
+ static scopes = {
19
+ timeStamped: query =>
20
+ query
21
+ .select('createdAt', 'updatedAt')
22
+ }
23
+
24
+ static hooks = {
25
+ 'before:insert'({ inputItems }) {
26
+ const now = new Date()
27
+ for (const item of inputItems) {
28
+ item.createdAt = now
29
+ item.updatedAt = now
30
+ }
31
+ },
32
+
33
+ 'before:update'({ inputItems }) {
34
+ const now = new Date()
35
+ for (const item of inputItems) {
36
+ item.updatedAt = now
37
+ }
38
+ }
39
+ }
40
+ }
41
+ )
@@ -0,0 +1,171 @@
1
+ import bcrypt from 'bcryptjs'
2
+ import passport from 'koa-passport'
3
+ import { Strategy as LocalStrategy } from 'passport-local'
4
+ import { mixin, asArray } from '@ditojs/utils'
5
+ import { AuthenticationError } from '../errors/index.js'
6
+
7
+ export const UserMixin = mixin(
8
+ Model =>
9
+ class extends Model {
10
+ static options = {
11
+ usernameProperty: 'username',
12
+ passwordProperty: 'password',
13
+ // This option can be used to specify (eager) scopes to be applied when
14
+ // the user is deserialized from the session.
15
+ sessionScope: undefined
16
+ }
17
+
18
+ static get properties() {
19
+ const {
20
+ usernameProperty,
21
+ passwordProperty
22
+ } = this.definition.options
23
+ return {
24
+ [usernameProperty]: {
25
+ type: 'string',
26
+ required: true
27
+ },
28
+
29
+ // `password` isn't stored, but this is required for validation:
30
+ [passwordProperty]: {
31
+ type: 'string',
32
+ computed: true
33
+ },
34
+
35
+ hash: {
36
+ type: 'string',
37
+ hidden: true
38
+ },
39
+
40
+ lastLogin: {
41
+ type: 'timestamp',
42
+ nullable: true
43
+ }
44
+ }
45
+ }
46
+
47
+ get password() {
48
+ // Nice try ;)
49
+ return undefined
50
+ }
51
+
52
+ set password(password) {
53
+ this.hash = bcrypt.hashSync(password, bcrypt.genSaltSync(10))
54
+ }
55
+
56
+ async $verifyPassword(password) {
57
+ return bcrypt.compare(password, this.hash)
58
+ }
59
+
60
+ $hasRole(...roles) {
61
+ // Support an optional `roles` array on the model that can contain roles
62
+ return this.roles?.find(role => roles.includes(role)) || false
63
+ }
64
+
65
+ $hasOwner(owner) {
66
+ return this.$is(owner)
67
+ }
68
+
69
+ $isLoggedIn(ctx) {
70
+ return this.$is(ctx.state.user)
71
+ }
72
+
73
+ static setup() {
74
+ userClasses[this.name] = this
75
+ const {
76
+ usernameProperty,
77
+ passwordProperty
78
+ } = this.definition.options
79
+ passport.use(
80
+ this.name,
81
+ new LocalStrategy(
82
+ {
83
+ usernameField: usernameProperty,
84
+ passwordField: passwordProperty,
85
+ // Wee need the `req` object, so we can get the active database
86
+ // transaction through `req.ctx.transaction`:
87
+ passReqToCallback: true
88
+ },
89
+ async (req, username, password, done) => {
90
+ try {
91
+ const user = await this.sessionQuery(
92
+ req.ctx.transaction
93
+ ).findOne(usernameProperty, username)
94
+ const res =
95
+ user && (await user.$verifyPassword(password))
96
+ ? user
97
+ : null
98
+ done(null, res)
99
+ } catch (err) {
100
+ done(err)
101
+ }
102
+ }
103
+ )
104
+ )
105
+ }
106
+
107
+ static async login(ctx, options) {
108
+ // Unfortunately koa-passport isn't promisified yet so do some wrapping:
109
+ return new Promise((resolve, reject) => {
110
+ // Use a custom callback to handle authentication, see:
111
+ // http://www.passportjs.org/docs/downloads/html/#custom-callback
112
+ passport.authenticate(
113
+ this.name,
114
+ async (err, user, message, status) => {
115
+ if (err) {
116
+ reject(err)
117
+ } else if (user) {
118
+ try {
119
+ await ctx.login(user, options)
120
+ resolve(user)
121
+ } catch (err) {
122
+ reject(err)
123
+ }
124
+ } else {
125
+ reject(
126
+ new AuthenticationError(
127
+ message || 'Password or username is incorrect',
128
+ status
129
+ )
130
+ )
131
+ }
132
+ }
133
+ )(ctx)
134
+ })
135
+ }
136
+
137
+ static sessionQuery(trx) {
138
+ return this.query(trx).withScope(
139
+ ...asArray(this.definition.options.sessionScope)
140
+ )
141
+ }
142
+ }
143
+ )
144
+
145
+ const userClasses = {}
146
+
147
+ // NOTE: We can't use toCallback() here since passport checks function arity to
148
+ // determine sequence of arguments received, and `req` would not be included:
149
+ passport.serializeUser((req, user, done) => {
150
+ // To support multiple user model classes, use both the model class name and
151
+ // id as identifier.
152
+ const modelName = user?.constructor.name
153
+ const identifier =
154
+ modelName && userClasses[modelName]
155
+ ? `${modelName}-${user.id}`
156
+ : null
157
+ done(null, identifier)
158
+ })
159
+
160
+ passport.deserializeUser(async (req, identifier, done) => {
161
+ const [modelName, userId] = identifier.split('-')
162
+ const userClass = userClasses[modelName]
163
+ try {
164
+ const user = userClass
165
+ ? await userClass.sessionQuery(req.ctx.transaction).findById(userId)
166
+ : null
167
+ done(null, user)
168
+ } catch (err) {
169
+ done(err)
170
+ }
171
+ })
@@ -0,0 +1,4 @@
1
+ export * from './AssetMixin.js'
2
+ export * from './SessionMixin.js'
3
+ export * from './TimeStampedMixin.js'
4
+ export * from './UserMixin.js'
@@ -0,0 +1,4 @@
1
+ import { AssetMixin } from '../mixins/index.js'
2
+ import { Model } from './Model.js'
3
+
4
+ export const AssetModel = AssetMixin(Model)