@ditojs/server 2.92.0 → 2.94.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ditojs/server",
3
- "version": "2.92.0",
3
+ "version": "2.94.1",
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
6
  "repository": "https://github.com/ditojs/dito/tree/main/packages/server",
@@ -12,23 +12,20 @@
12
12
  "src/",
13
13
  "types/"
14
14
  ],
15
- "scripts": {
16
- "types": "tsc --noEmit --esModuleInterop ./types/index.d.ts"
17
- },
18
15
  "bin": {
19
16
  "dito": "./src/cli/index.js"
20
17
  },
21
18
  "engines": {
22
- "node": ">= 20.0.0"
19
+ "node": ">= 24.0.0"
23
20
  },
24
21
  "browserslist": [
25
22
  "node >= 18"
26
23
  ],
27
24
  "dependencies": {
28
- "@ditojs/admin": "^2.92.0",
29
- "@ditojs/build": "^2.92.0",
30
- "@ditojs/router": "^2.92.0",
31
- "@ditojs/utils": "^2.92.0",
25
+ "@ditojs/admin": "^2.94.1",
26
+ "@ditojs/build": "^2.94.0",
27
+ "@ditojs/router": "^2.94.0",
28
+ "@ditojs/utils": "^2.94.0",
32
29
  "@koa/cors": "^5.0.0",
33
30
  "@koa/etag": "^5.0.2",
34
31
  "@koa/multer": "^4.0.0",
@@ -39,9 +36,9 @@
39
36
  "bytes": "^3.1.2",
40
37
  "data-uri-to-buffer": "^7.0.0",
41
38
  "eventemitter2": "^6.4.9",
42
- "file-type": "^21.3.3",
39
+ "file-type": "^22.0.0",
43
40
  "helmet": "^8.1.0",
44
- "koa": "^3.1.2",
41
+ "koa": "^3.2.0",
45
42
  "koa-bodyparser": "^4.4.1",
46
43
  "koa-compose": "^4.1.0",
47
44
  "koa-compress": "^5.2.1",
@@ -61,12 +58,12 @@
61
58
  "passport-local": "^1.0.0",
62
59
  "passthrough-counter": "^1.0.0",
63
60
  "picocolors": "^1.1.1",
64
- "picomatch": "^4.0.3",
61
+ "picomatch": "^4.0.4",
65
62
  "pino": "^10.3.1",
66
63
  "pino-pretty": "^13.1.3",
67
64
  "pluralize": "^8.0.0",
68
65
  "repl": "^0.1.3",
69
- "type-fest": "^5.4.4",
66
+ "type-fest": "^5.5.0",
70
67
  "uuid": "^13.0.0"
71
68
  },
72
69
  "peerDependencies": {
@@ -86,9 +83,12 @@
86
83
  "@types/koa__cors": "^5.0.1",
87
84
  "@types/koa__multer": "^2.0.8",
88
85
  "@types/node": "^25.5.0",
89
- "knex": "^3.1.0",
86
+ "knex": "^3.2.7",
90
87
  "objection": "^3.1.5",
91
- "typescript": "^5.9.3"
88
+ "typescript": "^6.0.2"
92
89
  },
93
- "gitHead": "7d2288aa487ec5c8607b2079a29e2d8793fbd07f"
94
- }
90
+ "gitHead": "7bb40e9e490632e20ceaf92907b4ff7b27484e26",
91
+ "scripts": {
92
+ "types": "tsc --noEmit --esModuleInterop ./types/index.d.ts"
93
+ }
94
+ }
@@ -833,9 +833,11 @@ export class Application extends Koa {
833
833
  this.server = await new Promise(resolve => {
834
834
  const server = this.listen(this.config.server, () => {
835
835
  const { address, port } = server.address()
836
- console.info(
837
- `Dito.js server started at http://${address}:${port}`
838
- )
836
+ if (Object.keys(this.config.log).length > 0) {
837
+ console.info(
838
+ `Dito.js server started at http://${address}:${port}`
839
+ )
840
+ }
839
841
  resolve(server)
840
842
  })
841
843
  })
@@ -900,9 +902,13 @@ export class Application extends Koa {
900
902
  async createAssets(storage, files, count = 0, transaction = null) {
901
903
  const AssetModel = this.getModel('Asset')
902
904
  if (AssetModel) {
905
+ // Shallow-clone file objects to avoid mutating the originals, since
906
+ // $parseJson() → convertAssetFile() deletes the signature.
907
+ // The originals may still be needed (e.g. sent as upload response).
908
+ // Shallow clone is sufficient as file objects are flat (scalar values).
903
909
  const assets = files.map(file => ({
904
910
  key: file.key,
905
- file,
911
+ file: { ...file },
906
912
  storage: storage.name,
907
913
  count
908
914
  }))
@@ -1007,6 +1013,9 @@ export class Application extends Koa {
1007
1013
  }
1008
1014
  }
1009
1015
  const importedFile = await storage.addFile(file, data)
1016
+ // Sign the imported foreign file so it passes verification when
1017
+ // createAssets() triggers $parseJson() → convertAssetFile().
1018
+ storage.signAssetFile(importedFile)
1010
1019
  await this.createAssets(storage, [importedFile], 0, transaction)
1011
1020
  importedFiles.push(importedFile)
1012
1021
  // Merge back the changed file properties into the actual file
File without changes
File without changes
@@ -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.getStorage(storage)?.convertAssetFile(file)
73
+ this.constructor.app
74
+ .getStorage(storage)
75
+ ?.convertAssetFile(file, { trusted })
74
76
  }
75
77
  return json
76
78
  }
@@ -12,6 +12,7 @@ import {
12
12
  parseDataPath,
13
13
  normalizeDataPath,
14
14
  getValueAtDataPath,
15
+ setValueAtDataPath,
15
16
  mapConcurrently,
16
17
  assignDeeply,
17
18
  deprecate
@@ -609,11 +610,11 @@ export class Model extends objection.Model {
609
610
  }
610
611
  // Also run through normal $parseJson(), for handling of `Date` and
611
612
  // `AssetFile`.
612
- return this.$parseJson(json)
613
+ return this.$parseJson(json, { trusted: true })
613
614
  }
614
615
 
615
616
  // @override
616
- $parseJson(json) {
617
+ $parseJson(json, { trusted = false } = {}) {
617
618
  const { constructor } = this
618
619
  for (const key of constructor.dateAttributes) {
619
620
  const date = json[key]
@@ -623,25 +624,9 @@ export class Model extends objection.Model {
623
624
  }
624
625
  // Convert plain asset files objects to AssetFile instances with references
625
626
  // to the linked storage.
626
- const { assets } = constructor.definition
627
- if (assets) {
628
- for (const dataPath in assets) {
629
- const storage = constructor.app.getStorage(assets[dataPath].storage)
630
- const data = getValueAtDataPath(json, dataPath, () => null)
631
- if (data) {
632
- const convertToAssetFiles = data => {
633
- if (data) {
634
- if (isArray(data)) {
635
- data.forEach(convertToAssetFiles)
636
- } else {
637
- storage.convertAssetFile(data)
638
- }
639
- }
640
- }
641
- convertToAssetFiles(data)
642
- }
643
- }
644
- }
627
+ this.constructor._forEachAssetFile(json, (file, storage) => {
628
+ storage.convertAssetFile(file, { trusted })
629
+ })
645
630
  return json
646
631
  }
647
632
 
@@ -651,6 +636,9 @@ export class Model extends objection.Model {
651
636
  for (const key of this.constructor.hiddenAttributes) {
652
637
  delete json[key]
653
638
  }
639
+ // Sign asset files so clients can send them back with valid signatures.
640
+ // Clone file objects to avoid mutating the model's internals.
641
+ this.constructor._signAssetFiles(json)
654
642
  return json
655
643
  }
656
644
 
@@ -1014,6 +1002,42 @@ export class Model extends objection.Model {
1014
1002
 
1015
1003
  // Assets handling
1016
1004
 
1005
+ static _forEachAssetFile(json, callback) {
1006
+ const { assets } = this.definition
1007
+ if (assets) {
1008
+ for (const dataPath in assets) {
1009
+ const data = getValueAtDataPath(json, dataPath, noop)
1010
+ if (!data) continue
1011
+ const storage = this.app.getStorage(assets[dataPath].storage)
1012
+ forEachAssetFile(data, storage, callback)
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ static _mapAssetFiles(json, callback) {
1018
+ const { assets } = this.definition
1019
+ if (assets) {
1020
+ for (const dataPath in assets) {
1021
+ const data = getValueAtDataPath(json, dataPath, noop)
1022
+ if (!data) continue
1023
+ const storage = this.app.getStorage(assets[dataPath].storage)
1024
+ setValueAtDataPath(
1025
+ json,
1026
+ dataPath,
1027
+ mapAssetFiles(data, storage, callback)
1028
+ )
1029
+ }
1030
+ }
1031
+ }
1032
+
1033
+ static _signAssetFiles(json) {
1034
+ this._mapAssetFiles(json, (file, storage) => {
1035
+ const signed = { ...file }
1036
+ storage.signAssetFile(signed)
1037
+ return signed
1038
+ })
1039
+ }
1040
+
1017
1041
  static _configureAssetsHooks(assets) {
1018
1042
  const assetDataPaths = Object.keys(assets)
1019
1043
 
@@ -1171,8 +1195,27 @@ function loadAssetDataPaths(query, dataPaths) {
1171
1195
  )
1172
1196
  }
1173
1197
 
1198
+ const noop = () => {}
1199
+
1200
+ function forEachAssetFile(data, storage, callback) {
1201
+ if (isArray(data)) {
1202
+ for (const item of data) {
1203
+ forEachAssetFile(item, storage, callback)
1204
+ }
1205
+ } else if (data) {
1206
+ callback(data, storage)
1207
+ }
1208
+ }
1209
+
1210
+ function mapAssetFiles(data, storage, callback) {
1211
+ if (isArray(data)) {
1212
+ return data.map(item => mapAssetFiles(item, storage, callback))
1213
+ }
1214
+ return data ? callback(data, storage) : data
1215
+ }
1216
+
1174
1217
  function getValueAtAssetDataPath(item, path) {
1175
- return getValueAtDataPath(item, path, () => undefined)
1218
+ return getValueAtDataPath(item, path, noop)
1176
1219
  }
1177
1220
 
1178
1221
  function getFilesPerAssetDataPath(items, dataPaths) {
@@ -2,6 +2,7 @@ import fs from 'fs/promises'
2
2
  import path from 'path'
3
3
  import multer from '@koa/multer'
4
4
  import { mapConcurrently } from '@ditojs/utils'
5
+ import { removeIfEmpty } from '../utils/fs.js'
5
6
  import { Storage } from './Storage.js'
6
7
 
7
8
  export class DiskStorage extends Storage {
@@ -11,6 +12,7 @@ export class DiskStorage extends Storage {
11
12
  if (!this.path) {
12
13
  throw new Error(`Missing configuration (path) for storage ${this.name}`)
13
14
  }
15
+ this.nestedFolders = this.config.nestedFolders ?? true
14
16
  this.storage = multer.diskStorage({
15
17
  destination: (req, storageFile, cb) => {
16
18
  // Add `storageFile.key` property to internal storage file object.
@@ -50,24 +52,13 @@ export class DiskStorage extends Storage {
50
52
  async _removeFile(file) {
51
53
  const filePath = this._getFilePath(file)
52
54
  await fs.unlink(filePath)
53
- const removeIfEmpty = async dir => {
54
- if ((await fs.readdir(dir)).length === 0) {
55
- try {
56
- await fs.rmdir(dir)
57
- } catch (err) {
58
- // The directory may already have been deleted by another async call,
59
- // fail silently here in this case.
60
- if (err.code !== 'ENOENT') {
61
- throw err
62
- }
63
- }
55
+ if (this.nestedFolders) {
56
+ // Clean up nested folders created with first two chars of `file.key`:
57
+ const dir = path.dirname(filePath)
58
+ if (await removeIfEmpty(dir)) {
59
+ await removeIfEmpty(path.dirname(dir))
64
60
  }
65
61
  }
66
- // Clean up nested folders created with first two chars of `file.key` also:
67
- const dir = path.dirname(filePath)
68
- const parentDir = path.dirname(dir)
69
- await removeIfEmpty(dir)
70
- await removeIfEmpty(parentDir)
71
62
  }
72
63
 
73
64
  // @override
@@ -77,6 +68,16 @@ export class DiskStorage extends Storage {
77
68
 
78
69
  // @override
79
70
  async _listKeys() {
71
+ if (!this.nestedFolders) {
72
+ const files = []
73
+ for (const file of await fs.readdir(this._getPath())) {
74
+ if (!file.startsWith('.')) {
75
+ files.push(file)
76
+ }
77
+ }
78
+ return files
79
+ }
80
+
80
81
  const readDir = (...parts) =>
81
82
  fs.readdir(this._getPath(...parts), { withFileTypes: true })
82
83
 
@@ -109,6 +110,8 @@ export class DiskStorage extends Storage {
109
110
  _getNestedFolder(key, posix = false) {
110
111
  // Store files in nested folders created with the first two chars of the
111
112
  // key, for faster access & management with large amounts of files.
112
- return (posix ? path.posix : path).join(key[0], key[1])
113
+ return this.nestedFolders
114
+ ? (posix ? path.posix : path).join(key[0], key[1])
115
+ : ''
113
116
  }
114
117
  }
@@ -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
  import { resolveFileUrl } from '../utils/asset.js'
10
12
 
11
13
  const storageClasses = {}
@@ -80,7 +82,21 @@ export class Storage {
80
82
  )
81
83
  }
82
84
 
83
- convertAssetFile(file) {
85
+ convertAssetFile(file, { trusted = false } = {}) {
86
+ if (
87
+ !trusted &&
88
+ !(file instanceof AssetFile) &&
89
+ !this.isImportSourceAllowed(file.url) &&
90
+ !this.verifyAssetFile(file)
91
+ ) {
92
+ throw new AssetError(
93
+ `Invalid asset signature for file '${
94
+ file.name ?? file.key
95
+ }'`
96
+ )
97
+ }
98
+ // Remove signature once the data made it to here.
99
+ delete file.signature
84
100
  AssetFile.convert(file, this)
85
101
  }
86
102
 
@@ -94,7 +110,8 @@ export class Storage {
94
110
  url: this._getFileUrl(storageFile),
95
111
  // In case `config.readDimensions` is set:
96
112
  width: storageFile.width,
97
- height: storageFile.height
113
+ height: storageFile.height,
114
+ signature: this._signAssetKey(storageFile.key)
98
115
  }
99
116
  }
100
117
 
@@ -108,7 +125,7 @@ export class Storage {
108
125
  file.url = this._getFileUrl(file)
109
126
  // TODO: Support `config.readDimensions`, but this can only be done once
110
127
  // there are separate storage instances per model assets config!
111
- this.convertAssetFile(file)
128
+ this.convertAssetFile(file, { trusted: true })
112
129
  return file
113
130
  }
114
131
 
@@ -132,6 +149,48 @@ export class Storage {
132
149
  return this._getFileUrl(file)
133
150
  }
134
151
 
152
+ signAssetFile(file) {
153
+ file.signature = this._signAssetKey(file.key)
154
+ }
155
+
156
+ verifyAssetFile(file) {
157
+ if (file) {
158
+ const expected = this._signAssetKey(file.key)
159
+ try {
160
+ return crypto.timingSafeEqual(
161
+ Buffer.from(expected, 'hex'),
162
+ Buffer.from(file.signature, 'hex')
163
+ )
164
+ } catch {
165
+ // Catches missing or malformed signatures.
166
+ // Fall through to return false below.
167
+ }
168
+ }
169
+ return false
170
+ }
171
+
172
+ _signAssetKey(key) {
173
+ const secret = (
174
+ this.app.keys?.[0] ??
175
+ (Storage._fallbackSecret ??= this._createFallbackSecret())
176
+ )
177
+ return crypto
178
+ .createHmac('sha256', secret)
179
+ .update(key)
180
+ .digest('hex')
181
+ }
182
+
183
+ _createFallbackSecret() {
184
+ console.warn(
185
+ 'No app.keys configured for asset signatures. ' +
186
+ 'Please note that signatures will not survive ' +
187
+ 'process restarts, and users will be unable to ' +
188
+ 'update records with asset properties until they ' +
189
+ 'reload the page.'
190
+ )
191
+ return crypto.randomBytes(32)
192
+ }
193
+
135
194
  _getUrl(...parts) {
136
195
  return this.url
137
196
  ? 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
+ })
package/src/utils/fs.js CHANGED
@@ -8,3 +8,19 @@ export async function exists(path) {
8
8
  return false
9
9
  }
10
10
  }
11
+
12
+ export async function removeIfEmpty(dir) {
13
+ try {
14
+ if ((await fs.readdir(dir)).length === 0) {
15
+ await fs.rmdir(dir)
16
+ return true
17
+ }
18
+ } catch (err) {
19
+ // The directory may already have been deleted by another async call,
20
+ // fail silently here in this case.
21
+ if (err.code !== 'ENOENT') {
22
+ throw err
23
+ }
24
+ }
25
+ return false
26
+ }