@ditojs/server 2.85.2 → 2.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/src/cli/db/createMigration.js +4 -1
- package/src/controllers/Controller.js +6 -2
- package/src/mixins/AssetMixin.js +4 -2
- package/src/models/Model.js +3 -3
- package/src/storage/AssetFile.js +2 -0
- package/src/storage/Storage.js +40 -3
- package/src/storage/Storage.test.js +58 -0
- package/types/index.d.ts +1712 -349
- 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
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ditojs/server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.87.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Dito.js Server – Dito.js is a declarative and modern web framework, based on Objection.js, Koa.js and Vue.js",
|
|
6
|
-
"repository": "https://github.com/ditojs/dito/tree/
|
|
6
|
+
"repository": "https://github.com/ditojs/dito/tree/main/packages/server",
|
|
7
7
|
"author": "Jürg Lehni <juerg@scratchdisk.com> (http://scratchdisk.com)",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"main": "./src/index.js",
|
|
@@ -19,17 +19,16 @@
|
|
|
19
19
|
"dito": "./src/cli/index.js"
|
|
20
20
|
},
|
|
21
21
|
"engines": {
|
|
22
|
-
"node": ">= 20.0.0"
|
|
23
|
-
"yarn": ">= 4.0.0"
|
|
22
|
+
"node": ">= 20.0.0"
|
|
24
23
|
},
|
|
25
24
|
"browserslist": [
|
|
26
25
|
"node >= 18"
|
|
27
26
|
],
|
|
28
27
|
"dependencies": {
|
|
29
|
-
"@ditojs/admin": "^2.
|
|
30
|
-
"@ditojs/build": "^2.
|
|
31
|
-
"@ditojs/router": "^2.
|
|
32
|
-
"@ditojs/utils": "^2.
|
|
28
|
+
"@ditojs/admin": "^2.87.0",
|
|
29
|
+
"@ditojs/build": "^2.87.0",
|
|
30
|
+
"@ditojs/router": "^2.87.0",
|
|
31
|
+
"@ditojs/utils": "^2.87.0",
|
|
33
32
|
"@koa/cors": "^5.0.0",
|
|
34
33
|
"@koa/etag": "^5.0.2",
|
|
35
34
|
"@koa/multer": "^4.0.0",
|
|
@@ -38,7 +37,7 @@
|
|
|
38
37
|
"ajv-formats": "^3.0.1",
|
|
39
38
|
"bcryptjs": "^3.0.3",
|
|
40
39
|
"bytes": "^3.1.2",
|
|
41
|
-
"data-uri-to-buffer": "^
|
|
40
|
+
"data-uri-to-buffer": "^7.0.0",
|
|
42
41
|
"eventemitter2": "^6.4.9",
|
|
43
42
|
"file-type": "^21.3.1",
|
|
44
43
|
"helmet": "^8.1.0",
|
|
@@ -56,7 +55,7 @@
|
|
|
56
55
|
"leather": "^3.0.3",
|
|
57
56
|
"mime-types": "^3.0.2",
|
|
58
57
|
"multer": "^2.1.1",
|
|
59
|
-
"multer-s3": "
|
|
58
|
+
"multer-s3": "github:ditojs/multer-s3#dito",
|
|
60
59
|
"nanoid": "^5.1.6",
|
|
61
60
|
"parse-duration": "^2.1.5",
|
|
62
61
|
"passport-local": "^1.0.0",
|
|
@@ -77,18 +76,19 @@
|
|
|
77
76
|
"objection": "^3.0.1"
|
|
78
77
|
},
|
|
79
78
|
"devDependencies": {
|
|
80
|
-
"@aws-sdk/client-s3": "^3.
|
|
81
|
-
"@aws-sdk/lib-storage": "^3.
|
|
79
|
+
"@aws-sdk/client-s3": "^3.1006.0",
|
|
80
|
+
"@aws-sdk/lib-storage": "^3.1006.0",
|
|
82
81
|
"@types/koa-bodyparser": "^4.3.13",
|
|
83
82
|
"@types/koa-compress": "^4.0.7",
|
|
84
83
|
"@types/koa-response-time": "^2.1.5",
|
|
85
84
|
"@types/koa-session": "^6.4.5",
|
|
86
85
|
"@types/koa-static": "^4.0.4",
|
|
87
86
|
"@types/koa__cors": "^5.0.1",
|
|
88
|
-
"@types/
|
|
87
|
+
"@types/koa__multer": "^2.0.8",
|
|
88
|
+
"@types/node": "^25.4.0",
|
|
89
89
|
"knex": "^3.1.0",
|
|
90
90
|
"objection": "^3.1.5",
|
|
91
91
|
"typescript": "^5.9.3"
|
|
92
92
|
},
|
|
93
|
-
"gitHead": "
|
|
93
|
+
"gitHead": "cbd05e604a1d212dfc91f9686b43bef75815a409"
|
|
94
94
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import path from 'path'
|
|
2
2
|
import fs from 'fs/promises'
|
|
3
3
|
import pico from 'picocolors'
|
|
4
|
-
import { getRelationClass, isThroughRelationClass } from '@ditojs/server'
|
|
5
4
|
import {
|
|
6
5
|
isObject,
|
|
7
6
|
isArray,
|
|
@@ -9,6 +8,10 @@ import {
|
|
|
9
8
|
deindent,
|
|
10
9
|
capitalize
|
|
11
10
|
} from '@ditojs/utils'
|
|
11
|
+
import {
|
|
12
|
+
getRelationClass,
|
|
13
|
+
isThroughRelationClass
|
|
14
|
+
} from '../../schema/relations.js'
|
|
12
15
|
import { exists } from '../../utils/fs.js'
|
|
13
16
|
|
|
14
17
|
const typeToKnex = {
|
|
@@ -534,8 +534,12 @@ export class Controller {
|
|
|
534
534
|
} else if (isFunction(authorize)) {
|
|
535
535
|
return async (ctx, member) => {
|
|
536
536
|
const res = await authorize(ctx, member)
|
|
537
|
-
//
|
|
538
|
-
|
|
537
|
+
// Deny access if the function forgot to return a value.
|
|
538
|
+
// Otherwise pass res through `processAuthorize()` to
|
|
539
|
+
// support strings & arrays.
|
|
540
|
+
return res === undefined
|
|
541
|
+
? false
|
|
542
|
+
: this.processAuthorize(res)(ctx, member)
|
|
539
543
|
}
|
|
540
544
|
} else if (isString(authorize) || isArray(authorize)) {
|
|
541
545
|
return async (ctx, member) => {
|
package/src/mixins/AssetMixin.js
CHANGED
|
@@ -66,11 +66,13 @@ export const AssetMixin = mixin(
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
// @override
|
|
69
|
-
$parseJson(json) {
|
|
69
|
+
$parseJson(json, { trusted = false } = {}) {
|
|
70
70
|
const { file, storage } = json
|
|
71
71
|
// Convert `AssetMixin#file` to an `AssetFile` instance:
|
|
72
72
|
if (file && storage) {
|
|
73
|
-
this.constructor.app
|
|
73
|
+
this.constructor.app
|
|
74
|
+
.getStorage(storage)
|
|
75
|
+
?.convertAssetFile(file, { trusted })
|
|
74
76
|
}
|
|
75
77
|
return json
|
|
76
78
|
}
|
package/src/models/Model.js
CHANGED
|
@@ -609,11 +609,11 @@ export class Model extends objection.Model {
|
|
|
609
609
|
}
|
|
610
610
|
// Also run through normal $parseJson(), for handling of `Date` and
|
|
611
611
|
// `AssetFile`.
|
|
612
|
-
return this.$parseJson(json)
|
|
612
|
+
return this.$parseJson(json, { trusted: true })
|
|
613
613
|
}
|
|
614
614
|
|
|
615
615
|
// @override
|
|
616
|
-
$parseJson(json) {
|
|
616
|
+
$parseJson(json, { trusted = false } = {}) {
|
|
617
617
|
const { constructor } = this
|
|
618
618
|
for (const key of constructor.dateAttributes) {
|
|
619
619
|
const date = json[key]
|
|
@@ -634,7 +634,7 @@ export class Model extends objection.Model {
|
|
|
634
634
|
if (isArray(data)) {
|
|
635
635
|
data.forEach(convertToAssetFiles)
|
|
636
636
|
} else {
|
|
637
|
-
storage.convertAssetFile(data)
|
|
637
|
+
storage.convertAssetFile(data, { trusted })
|
|
638
638
|
}
|
|
639
639
|
}
|
|
640
640
|
}
|
package/src/storage/AssetFile.js
CHANGED
package/src/storage/Storage.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import path from 'path'
|
|
2
2
|
import { URL } from 'url'
|
|
3
|
+
import crypto from 'crypto'
|
|
3
4
|
import multer from '@koa/multer'
|
|
4
5
|
import picomatch from 'picomatch'
|
|
5
6
|
import { PassThrough } from 'stream'
|
|
6
7
|
import { readMediaAttributes } from 'leather'
|
|
7
8
|
import { hyphenate, toPromiseCallback } from '@ditojs/utils'
|
|
8
9
|
import { AssetFile } from './AssetFile.js'
|
|
10
|
+
import { AssetError } from '../errors/AssetError.js'
|
|
9
11
|
|
|
10
12
|
const storageClasses = {}
|
|
11
13
|
|
|
@@ -76,7 +78,17 @@ export class Storage {
|
|
|
76
78
|
return picomatch.isMatch(url, this.config.allowedImports || [])
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
convertAssetFile(file) {
|
|
81
|
+
convertAssetFile(file, { trusted = false } = {}) {
|
|
82
|
+
if (
|
|
83
|
+
!trusted &&
|
|
84
|
+
!this._verifyAssetKey(file.key, file.signature)
|
|
85
|
+
) {
|
|
86
|
+
throw new AssetError(
|
|
87
|
+
`Invalid asset signature for file '${
|
|
88
|
+
file.name ?? file.key
|
|
89
|
+
}'`
|
|
90
|
+
)
|
|
91
|
+
}
|
|
80
92
|
AssetFile.convert(file, this)
|
|
81
93
|
}
|
|
82
94
|
|
|
@@ -90,7 +102,8 @@ export class Storage {
|
|
|
90
102
|
url: this._getFileUrl(storageFile),
|
|
91
103
|
// In case `config.readDimensions` is set:
|
|
92
104
|
width: storageFile.width,
|
|
93
|
-
height: storageFile.height
|
|
105
|
+
height: storageFile.height,
|
|
106
|
+
signature: this._signAssetKey(storageFile.key)
|
|
94
107
|
}
|
|
95
108
|
}
|
|
96
109
|
|
|
@@ -104,7 +117,7 @@ export class Storage {
|
|
|
104
117
|
file.url = this._getFileUrl(file)
|
|
105
118
|
// TODO: Support `config.readDimensions`, but this can only be done once
|
|
106
119
|
// there are separate storage instances per model assets config!
|
|
107
|
-
this.convertAssetFile(file)
|
|
120
|
+
this.convertAssetFile(file, { trusted: true })
|
|
108
121
|
return file
|
|
109
122
|
}
|
|
110
123
|
|
|
@@ -128,6 +141,30 @@ export class Storage {
|
|
|
128
141
|
return this._getFileUrl(file)
|
|
129
142
|
}
|
|
130
143
|
|
|
144
|
+
_signAssetKey(key) {
|
|
145
|
+
const secret = (
|
|
146
|
+
this.app.keys?.[0] ??
|
|
147
|
+
(Storage._fallbackSecret ??= crypto.randomBytes(32))
|
|
148
|
+
)
|
|
149
|
+
return crypto
|
|
150
|
+
.createHmac('sha256', secret)
|
|
151
|
+
.update(key)
|
|
152
|
+
.digest('hex')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_verifyAssetKey(key, signature) {
|
|
156
|
+
if (!key || !signature) return false
|
|
157
|
+
const expected = this._signAssetKey(key)
|
|
158
|
+
try {
|
|
159
|
+
return crypto.timingSafeEqual(
|
|
160
|
+
Buffer.from(expected, 'hex'),
|
|
161
|
+
Buffer.from(signature, 'hex')
|
|
162
|
+
)
|
|
163
|
+
} catch {
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
131
168
|
_getUrl(...parts) {
|
|
132
169
|
return this.url
|
|
133
170
|
? new URL(path.posix.join(...parts), this.url).toString()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Storage } from './Storage.js'
|
|
2
|
+
|
|
3
|
+
function createStorage(keys = ['test-secret']) {
|
|
4
|
+
const app = { keys }
|
|
5
|
+
return new Storage(app, { name: 'test' })
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function simulateUpload(storage, key, originalname) {
|
|
9
|
+
return storage.convertStorageFile({
|
|
10
|
+
key,
|
|
11
|
+
originalname,
|
|
12
|
+
mimetype: 'image/png',
|
|
13
|
+
size: 2048
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('Storage: asset key signature verification', () => {
|
|
18
|
+
it('preserves the key through a full upload-then-save cycle', () => {
|
|
19
|
+
const storage = createStorage()
|
|
20
|
+
const uploaded = simulateUpload(storage, 'real-key.png', 'photo.png')
|
|
21
|
+
const fromClient = { ...uploaded }
|
|
22
|
+
storage.convertAssetFile(fromClient, { trusted: false })
|
|
23
|
+
expect(fromClient.key).toBe('real-key.png')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('throws when a forged key is submitted', () => {
|
|
27
|
+
const storage = createStorage()
|
|
28
|
+
const file = { key: 'victim-key.png', name: 'photo.png' }
|
|
29
|
+
expect(() => {
|
|
30
|
+
storage.convertAssetFile(file, { trusted: false })
|
|
31
|
+
}).toThrow('Invalid asset signature')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('throws when a valid signature is paired with a swapped key', () => {
|
|
35
|
+
const storage = createStorage()
|
|
36
|
+
const uploaded = simulateUpload(storage, 'legit.png', 'legit.png')
|
|
37
|
+
const forged = { ...uploaded, key: 'victim-file.png' }
|
|
38
|
+
expect(() => {
|
|
39
|
+
storage.convertAssetFile(forged, { trusted: false })
|
|
40
|
+
}).toThrow('Invalid asset signature')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('does not throw when addFile() converts a server-created file', async () => {
|
|
44
|
+
const storage = createStorage()
|
|
45
|
+
const file = { key: 'imported.png', name: 'import.png' }
|
|
46
|
+
const data = Buffer.from('fake-image-data')
|
|
47
|
+
await storage.addFile(file, data)
|
|
48
|
+
expect(file.key).toBe('imported.png')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('strips signature after conversion', () => {
|
|
52
|
+
const storage = createStorage()
|
|
53
|
+
const uploaded = simulateUpload(storage, 'upload.png', 'photo.png')
|
|
54
|
+
const fromClient = { ...uploaded }
|
|
55
|
+
storage.convertAssetFile(fromClient, { trusted: false })
|
|
56
|
+
expect('signature' in fromClient).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
})
|