@ditojs/server 2.93.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 +10 -10
- package/src/app/Application.js +13 -4
- package/src/cli/db/createMigration.js +0 -0
- package/src/cli/db/seed.js +0 -0
- package/src/mixins/AssetMixin.js +4 -2
- package/src/models/Model.js +65 -22
- package/src/storage/DiskStorage.js +20 -17
- package/src/storage/Storage.js +62 -3
- package/src/storage/Storage.test.js +58 -0
- package/src/utils/fs.js +16 -0
- package/types/index.d.ts +703 -193
- package/types/tests/application.test-d.ts +0 -26
- package/types/tests/controller.test-d.ts +0 -113
- package/types/tests/errors.test-d.ts +0 -53
- package/types/tests/fixtures.ts +0 -19
- package/types/tests/model.test-d.ts +0 -193
- package/types/tests/query-builder.test-d.ts +0 -106
- package/types/tests/relation.test-d.ts +0 -83
- package/types/tests/storage.test-d.ts +0 -113
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ditojs/server",
|
|
3
|
-
"version": "2.
|
|
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,9 +12,6 @@
|
|
|
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
|
},
|
|
@@ -25,10 +22,10 @@
|
|
|
25
22
|
"node >= 18"
|
|
26
23
|
],
|
|
27
24
|
"dependencies": {
|
|
28
|
-
"@ditojs/admin": "^2.
|
|
29
|
-
"@ditojs/build": "^2.
|
|
30
|
-
"@ditojs/router": "^2.
|
|
31
|
-
"@ditojs/utils": "^2.
|
|
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",
|
|
@@ -90,5 +87,8 @@
|
|
|
90
87
|
"objection": "^3.1.5",
|
|
91
88
|
"typescript": "^6.0.2"
|
|
92
89
|
},
|
|
93
|
-
"gitHead": "
|
|
94
|
-
|
|
90
|
+
"gitHead": "7bb40e9e490632e20ceaf92907b4ff7b27484e26",
|
|
91
|
+
"scripts": {
|
|
92
|
+
"types": "tsc --noEmit --esModuleInterop ./types/index.d.ts"
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/app/Application.js
CHANGED
|
@@ -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
|
-
|
|
837
|
-
|
|
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
|
package/src/cli/db/seed.js
CHANGED
|
File without changes
|
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
|
@@ -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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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,
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
113
|
+
return this.nestedFolders
|
|
114
|
+
? (posix ? path.posix : path).join(key[0], key[1])
|
|
115
|
+
: ''
|
|
113
116
|
}
|
|
114
117
|
}
|
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
|
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
|
+
}
|