@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,31 @@
|
|
|
1
|
+
export const asset = {
|
|
2
|
+
type: 'object',
|
|
3
|
+
properties: {
|
|
4
|
+
key: {
|
|
5
|
+
type: 'string',
|
|
6
|
+
required: true
|
|
7
|
+
},
|
|
8
|
+
name: {
|
|
9
|
+
type: 'string',
|
|
10
|
+
required: true
|
|
11
|
+
},
|
|
12
|
+
type: {
|
|
13
|
+
type: 'string',
|
|
14
|
+
required: true
|
|
15
|
+
},
|
|
16
|
+
size: {
|
|
17
|
+
type: 'integer',
|
|
18
|
+
required: true
|
|
19
|
+
},
|
|
20
|
+
url: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
format: 'uri'
|
|
23
|
+
},
|
|
24
|
+
width: {
|
|
25
|
+
type: 'integer'
|
|
26
|
+
},
|
|
27
|
+
height: {
|
|
28
|
+
type: 'integer'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { camelize, hyphenate } from '@ditojs/utils'
|
|
2
|
+
|
|
3
|
+
export class Service {
|
|
4
|
+
initialized = false
|
|
5
|
+
#loggerName
|
|
6
|
+
|
|
7
|
+
constructor(app, name) {
|
|
8
|
+
this.app = app
|
|
9
|
+
this.name = camelize(
|
|
10
|
+
(name || this.constructor.name).match(/^(.*?)(?:Service|)$/)[1]
|
|
11
|
+
)
|
|
12
|
+
this.#loggerName = hyphenate(this.name)
|
|
13
|
+
this.config = null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
setup(config) {
|
|
17
|
+
this.config = config
|
|
18
|
+
this.app.on('before:start', () => this.start())
|
|
19
|
+
this.app.on('after:stop', () => this.stop())
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// @overridable
|
|
23
|
+
async initialize() {}
|
|
24
|
+
|
|
25
|
+
// @overridable
|
|
26
|
+
async start() {}
|
|
27
|
+
|
|
28
|
+
// @overridable
|
|
29
|
+
async stop() {}
|
|
30
|
+
|
|
31
|
+
get logger() {
|
|
32
|
+
const logger = this.app.requestLocals.logger ?? this.app.logger
|
|
33
|
+
return logger.child({ name: this.#loggerName })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Service.js'
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import mime from 'mime-types'
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid'
|
|
4
|
+
import { dataUriToBuffer } from 'data-uri-to-buffer'
|
|
5
|
+
import { isString } from '@ditojs/utils'
|
|
6
|
+
|
|
7
|
+
const SYMBOL_STORAGE = Symbol('storage')
|
|
8
|
+
const SYMBOL_DATA = Symbol('data')
|
|
9
|
+
|
|
10
|
+
export class AssetFile {
|
|
11
|
+
constructor({ name, data, type, width, height }) {
|
|
12
|
+
this.key = AssetFile.getUniqueKey(name)
|
|
13
|
+
this.name = name
|
|
14
|
+
// Set `type` before `data`, so it can be used as default in `set data`
|
|
15
|
+
this.type = type
|
|
16
|
+
this.width = width
|
|
17
|
+
this.height = height
|
|
18
|
+
this.data = data
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get storage() {
|
|
22
|
+
return this[SYMBOL_STORAGE] || null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get data() {
|
|
26
|
+
return this[SYMBOL_DATA] || null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
set data(data) {
|
|
30
|
+
if (isString(data)) {
|
|
31
|
+
if (data.startsWith('data:')) {
|
|
32
|
+
const { type, buffer } = dataUriToBuffer(data)
|
|
33
|
+
data = Buffer.from(buffer)
|
|
34
|
+
this.type ||= type || mime.lookup(this.name)
|
|
35
|
+
} else {
|
|
36
|
+
data = Buffer.from(data)
|
|
37
|
+
this.type ||= mime.lookup(this.name) || 'text/plain'
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
// Buffer & co.
|
|
41
|
+
data = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
|
42
|
+
this.type ||= (
|
|
43
|
+
data.type || // See Storage._readFile()
|
|
44
|
+
mime.lookup(this.name) ||
|
|
45
|
+
'application/octet-stream'
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
this.size = Buffer.byteLength(data)
|
|
49
|
+
setHiddenProperty(this, SYMBOL_DATA, data)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get path() {
|
|
53
|
+
return this.storage?.getFilePath(this)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async read() {
|
|
57
|
+
return this.storage?.readFile(this) || null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static convert(object, storage) {
|
|
61
|
+
Object.setPrototypeOf(object, AssetFile.prototype)
|
|
62
|
+
setHiddenProperty(object, SYMBOL_STORAGE, storage)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static create(options) {
|
|
66
|
+
return new AssetFile(options)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static getUniqueKey(name) {
|
|
70
|
+
return `${uuidv4()}${path.extname(name).toLowerCase()}`
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function setHiddenProperty(object, key, value) {
|
|
75
|
+
Object.defineProperty(object, key, {
|
|
76
|
+
configurable: true,
|
|
77
|
+
enumerable: false,
|
|
78
|
+
writable: true,
|
|
79
|
+
value
|
|
80
|
+
})
|
|
81
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'fs/promises'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import multer from '@koa/multer'
|
|
4
|
+
import { mapConcurrently } from '@ditojs/utils'
|
|
5
|
+
import { Storage } from './Storage.js'
|
|
6
|
+
|
|
7
|
+
export class DiskStorage extends Storage {
|
|
8
|
+
static type = 'disk'
|
|
9
|
+
|
|
10
|
+
setup() {
|
|
11
|
+
if (!this.path) {
|
|
12
|
+
throw new Error(`Missing configuration (path) for storage ${this.name}`)
|
|
13
|
+
}
|
|
14
|
+
this.storage = multer.diskStorage({
|
|
15
|
+
destination: (req, storageFile, cb) => {
|
|
16
|
+
// Add `storageFile.key` property to internal storage file object.
|
|
17
|
+
storageFile.key = this.getUniqueKey(storageFile.originalname)
|
|
18
|
+
const dir = this._getPath(this._getNestedFolder(storageFile.key))
|
|
19
|
+
fs.mkdir(dir, { recursive: true })
|
|
20
|
+
.then(() => cb(null, dir))
|
|
21
|
+
.catch(cb)
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
filename: (req, storageFile, cb) => {
|
|
25
|
+
// Use added `storageFile.key` property for multer's `filename` also.
|
|
26
|
+
cb(null, storageFile.key)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// @override
|
|
32
|
+
_getFilePath(file) {
|
|
33
|
+
return this._getPath(this._getNestedFolder(file.key), file.key)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// @override
|
|
37
|
+
_getFileUrl(file) {
|
|
38
|
+
return this._getUrl(this._getNestedFolder(file.key, true), file.key)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// @override
|
|
42
|
+
async _addFile(file, data) {
|
|
43
|
+
const filePath = this._getFilePath(file)
|
|
44
|
+
const dir = path.dirname(filePath)
|
|
45
|
+
await fs.mkdir(dir, { recursive: true })
|
|
46
|
+
await fs.writeFile(filePath, data)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// @override
|
|
50
|
+
async _removeFile(file) {
|
|
51
|
+
const filePath = this._getFilePath(file)
|
|
52
|
+
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
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
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
|
+
}
|
|
72
|
+
|
|
73
|
+
// @override
|
|
74
|
+
async _readFile(file) {
|
|
75
|
+
return fs.readFile(this._getFilePath(file))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// @override
|
|
79
|
+
async _listKeys() {
|
|
80
|
+
const readDir = (...parts) =>
|
|
81
|
+
fs.readdir(this._getPath(...parts), { withFileTypes: true })
|
|
82
|
+
|
|
83
|
+
const files = []
|
|
84
|
+
const list1 = await readDir()
|
|
85
|
+
await mapConcurrently(
|
|
86
|
+
list1,
|
|
87
|
+
async level1 => {
|
|
88
|
+
if (level1.isDirectory() && level1.name.length === 1) {
|
|
89
|
+
const list2 = await readDir(level1.name)
|
|
90
|
+
await mapConcurrently(
|
|
91
|
+
list2,
|
|
92
|
+
async level2 => {
|
|
93
|
+
if (level2.isDirectory() && level2.name.length === 1) {
|
|
94
|
+
const nestedFolder = this._getPath(level1.name, level2.name)
|
|
95
|
+
for (const file of await fs.readdir(nestedFolder)) {
|
|
96
|
+
if (!file.startsWith('.')) {
|
|
97
|
+
files.push(file)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
return files
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_getNestedFolder(key, posix = false) {
|
|
110
|
+
// Store files in nested folders created with the first two chars of the
|
|
111
|
+
// key, for faster access & management with large amounts of files.
|
|
112
|
+
return (posix ? path.posix : path).join(key[0], key[1])
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import multerS3 from 'multer-s3'
|
|
2
|
+
import { fileTypeFromBuffer } from 'file-type'
|
|
3
|
+
import { Storage } from './Storage.js'
|
|
4
|
+
import { PassThrough } from 'stream'
|
|
5
|
+
import consumers from 'stream/consumers'
|
|
6
|
+
import { readMediaAttributes } from 'leather'
|
|
7
|
+
|
|
8
|
+
export class S3Storage extends Storage {
|
|
9
|
+
static type = 's3'
|
|
10
|
+
|
|
11
|
+
s3 = null
|
|
12
|
+
acl = null
|
|
13
|
+
bucket = null
|
|
14
|
+
|
|
15
|
+
async setup() {
|
|
16
|
+
const {
|
|
17
|
+
name,
|
|
18
|
+
s3,
|
|
19
|
+
acl,
|
|
20
|
+
bucket,
|
|
21
|
+
...options
|
|
22
|
+
} = this.config
|
|
23
|
+
|
|
24
|
+
// "@aws-sdk/client-s3" is a peer-dependency, and importing it costly,
|
|
25
|
+
// so we do it lazily.
|
|
26
|
+
const { S3 } = await import('@aws-sdk/client-s3')
|
|
27
|
+
this.s3 = new S3(s3)
|
|
28
|
+
this.acl = acl
|
|
29
|
+
this.bucket = bucket
|
|
30
|
+
|
|
31
|
+
this.storage = multerS3({
|
|
32
|
+
s3: this.s3,
|
|
33
|
+
acl,
|
|
34
|
+
bucket,
|
|
35
|
+
...options,
|
|
36
|
+
|
|
37
|
+
key: (req, file, cb) => {
|
|
38
|
+
cb(null, this.getUniqueKey(file.originalname))
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
contentType: (req, file, cb) => {
|
|
42
|
+
const { mimetype, stream } = file
|
|
43
|
+
if (mimetype) {
|
|
44
|
+
// 1. Trust file.mimetype if provided.
|
|
45
|
+
cb(null, mimetype)
|
|
46
|
+
} else {
|
|
47
|
+
let data = null
|
|
48
|
+
|
|
49
|
+
const done = type => {
|
|
50
|
+
stream.off('data', onData)
|
|
51
|
+
const outStream = new PassThrough()
|
|
52
|
+
outStream.write(data)
|
|
53
|
+
stream.pipe(outStream)
|
|
54
|
+
cb(null, type, outStream)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const onData = chunk => {
|
|
58
|
+
if (!data) {
|
|
59
|
+
// 2. Try reading the mimetype from the first chunk.
|
|
60
|
+
const type = getFileTypeFromBuffer(chunk)
|
|
61
|
+
if (type) {
|
|
62
|
+
done(type)
|
|
63
|
+
} else {
|
|
64
|
+
// 3. If that fails, keep collecting all chunks and determine
|
|
65
|
+
// the mimetype using the full data.
|
|
66
|
+
stream.once('end', () =>
|
|
67
|
+
done(
|
|
68
|
+
getFileTypeFromBuffer(data) || 'application/octet-stream'
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
data = data ? Buffer.concat([data, chunk]) : chunk
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
stream.on('data', onData)
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
metadata: (req, file, cb) => {
|
|
81
|
+
// Store the determined width and height as meta-data on the s3 object
|
|
82
|
+
// as well. You never know, it may become useful :)
|
|
83
|
+
const { width, height } = file
|
|
84
|
+
if (width != null || height != null) {
|
|
85
|
+
cb(null, {
|
|
86
|
+
width: `${width}`,
|
|
87
|
+
height: `${height}`
|
|
88
|
+
})
|
|
89
|
+
} else {
|
|
90
|
+
cb(null, {})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// @override
|
|
97
|
+
_getFilePath(_file) {
|
|
98
|
+
// There is no "local" file-path to files on S3.
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// @override
|
|
103
|
+
_getFileUrl(file) {
|
|
104
|
+
return this._getUrl(file.key) ?? file.url
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// @override
|
|
108
|
+
async _addFile(file, data) {
|
|
109
|
+
const result = await this.s3.putObject({
|
|
110
|
+
Bucket: this.bucket,
|
|
111
|
+
ACL: this.acl,
|
|
112
|
+
Key: file.key,
|
|
113
|
+
ContentType: file.type,
|
|
114
|
+
Body: data
|
|
115
|
+
})
|
|
116
|
+
// In `Storage.addFile()` this will get overridden with the result of
|
|
117
|
+
// `_getUrl()` if it exists, but is used as a fallback otherwise,
|
|
118
|
+
// see `_getFileUrl()`.
|
|
119
|
+
file.url = result.Location
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// @override
|
|
123
|
+
async _removeFile(file) {
|
|
124
|
+
await this.s3.deleteObject({
|
|
125
|
+
Bucket: this.bucket,
|
|
126
|
+
Key: file.key
|
|
127
|
+
})
|
|
128
|
+
// TODO: Check for errors and throw?
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// @override
|
|
132
|
+
async _readFile(file) {
|
|
133
|
+
const {
|
|
134
|
+
ContentType: type,
|
|
135
|
+
Body: stream
|
|
136
|
+
} = await this.s3.getObject({
|
|
137
|
+
Bucket: this.bucket,
|
|
138
|
+
Key: file.key
|
|
139
|
+
})
|
|
140
|
+
const buffer = await consumers.buffer(stream)
|
|
141
|
+
// See `AssetFile.data` setter:
|
|
142
|
+
buffer.type = type
|
|
143
|
+
return buffer
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// @override
|
|
147
|
+
async _listKeys() {
|
|
148
|
+
const files = []
|
|
149
|
+
const params = { Bucket: this.bucket }
|
|
150
|
+
let result
|
|
151
|
+
do {
|
|
152
|
+
result = await this.s3.listObjectsV2(params)
|
|
153
|
+
for (const { Key: key } of result.Contents ?? []) {
|
|
154
|
+
files.push(key)
|
|
155
|
+
}
|
|
156
|
+
// Continue it if results are truncated.
|
|
157
|
+
params.ContinuationToken = result.NextContinuationToken
|
|
158
|
+
} while (result.IsTruncated)
|
|
159
|
+
return files
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getFileTypeFromBuffer(buffer) {
|
|
164
|
+
try {
|
|
165
|
+
// Use leather as fall-back for better media file mime type detection.
|
|
166
|
+
return fileTypeFromBuffer(buffer)?.mime || readMediaAttributes(buffer)?.mime
|
|
167
|
+
} catch {}
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import { URL } from 'url'
|
|
3
|
+
import multer from '@koa/multer'
|
|
4
|
+
import picomatch from 'picomatch'
|
|
5
|
+
import { PassThrough } from 'stream'
|
|
6
|
+
import { readMediaAttributes } from 'leather'
|
|
7
|
+
import { hyphenate, toPromiseCallback } from '@ditojs/utils'
|
|
8
|
+
import { AssetFile } from './AssetFile.js'
|
|
9
|
+
|
|
10
|
+
const storageClasses = {}
|
|
11
|
+
|
|
12
|
+
export class Storage {
|
|
13
|
+
initialized = false
|
|
14
|
+
|
|
15
|
+
constructor(app, config) {
|
|
16
|
+
this.app = app
|
|
17
|
+
this.config = config
|
|
18
|
+
this.name = config.name
|
|
19
|
+
this.url = config.url
|
|
20
|
+
this.path = config.path
|
|
21
|
+
// Use a default concurrency of 8 for storage IO, e.g. the importing of
|
|
22
|
+
// foreign assets.
|
|
23
|
+
this.concurrency = config.concurrency ?? 8
|
|
24
|
+
// The actual multer storage object.
|
|
25
|
+
this.storage = null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// @overridable
|
|
29
|
+
async setup() {}
|
|
30
|
+
|
|
31
|
+
// @overridable
|
|
32
|
+
async initialize() {}
|
|
33
|
+
|
|
34
|
+
static register(storageClass) {
|
|
35
|
+
const type = (
|
|
36
|
+
storageClass.type ||
|
|
37
|
+
hyphenate(storageClass.name.match(/^(.*?)(?:Storage|)$/)[1])
|
|
38
|
+
)
|
|
39
|
+
storageClass.type = type
|
|
40
|
+
storageClasses[type] = storageClass
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static get(type) {
|
|
44
|
+
return storageClasses[type] || null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getUploadStorage(config) {
|
|
48
|
+
// Returns a storage that inherits from this.storage but overrides
|
|
49
|
+
// _handleFile to pass on `config` to the call of `handleUpload()`
|
|
50
|
+
return this.storage
|
|
51
|
+
? Object.setPrototypeOf(
|
|
52
|
+
{
|
|
53
|
+
_handleFile: async (req, file, callback) => {
|
|
54
|
+
try {
|
|
55
|
+
callback(null, await this._handleUpload(req, file, config))
|
|
56
|
+
} catch (err) {
|
|
57
|
+
callback(err)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
this.storage
|
|
62
|
+
)
|
|
63
|
+
: null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getUploadHandler(config) {
|
|
67
|
+
const storage = this.getUploadStorage(config)
|
|
68
|
+
return storage ? multer({ ...config, storage }).any() : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getUniqueKey(name) {
|
|
72
|
+
return AssetFile.getUniqueKey(name)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
isImportSourceAllowed(url) {
|
|
76
|
+
return picomatch.isMatch(url, this.config.allowedImports || [])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
convertAssetFile(file) {
|
|
80
|
+
AssetFile.convert(file, this)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
convertStorageFile(storageFile) {
|
|
84
|
+
// Convert multer file object to our own file object format:
|
|
85
|
+
return {
|
|
86
|
+
key: storageFile.key,
|
|
87
|
+
name: storageFile.originalname,
|
|
88
|
+
type: storageFile.mimetype,
|
|
89
|
+
size: storageFile.size,
|
|
90
|
+
url: this._getFileUrl(storageFile),
|
|
91
|
+
// In case `config.readDimensions` is set:
|
|
92
|
+
width: storageFile.width,
|
|
93
|
+
height: storageFile.height
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
convertStorageFiles(storageFiles) {
|
|
98
|
+
return storageFiles.map(storageFile => this.convertStorageFile(storageFile))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async addFile(file, data) {
|
|
102
|
+
await this._addFile(file, data)
|
|
103
|
+
file.size = Buffer.byteLength(data)
|
|
104
|
+
file.url = this._getFileUrl(file)
|
|
105
|
+
// TODO: Support `config.readDimensions`, but this can only be done once
|
|
106
|
+
// there are separate storage instances per model assets config!
|
|
107
|
+
this.convertAssetFile(file)
|
|
108
|
+
return file
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async removeFile(file) {
|
|
112
|
+
await this._removeFile(file)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async readFile(file) {
|
|
116
|
+
return this._readFile(file)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async listKeys() {
|
|
120
|
+
return this._listKeys()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getFilePath(file) {
|
|
124
|
+
return this._getFilePath(file)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
getFileUrl(file) {
|
|
128
|
+
return this._getFileUrl(file)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_getUrl(...parts) {
|
|
132
|
+
return this.url
|
|
133
|
+
? new URL(path.posix.join(...parts), this.url).toString()
|
|
134
|
+
: undefined // So that it doesn't show up in JSON data.
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_getPath(...parts) {
|
|
138
|
+
return this.path
|
|
139
|
+
? path.resolve(this.app.basePath, this.path, ...parts)
|
|
140
|
+
: undefined // So that it doesn't show up in JSON data.
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// @overridable
|
|
144
|
+
_getFilePath(_file) {}
|
|
145
|
+
|
|
146
|
+
// @overridable
|
|
147
|
+
_getFileUrl(_file) {}
|
|
148
|
+
|
|
149
|
+
// @overridable
|
|
150
|
+
async _addFile(_file, _data) {}
|
|
151
|
+
|
|
152
|
+
// @overridable
|
|
153
|
+
async _removeFile(_file) {}
|
|
154
|
+
|
|
155
|
+
// @overridable
|
|
156
|
+
async _readFile(_file) {}
|
|
157
|
+
|
|
158
|
+
// @overridable
|
|
159
|
+
async _listKeys() {}
|
|
160
|
+
|
|
161
|
+
async _handleUpload(req, file, config) {
|
|
162
|
+
if (
|
|
163
|
+
config.readDimensions &&
|
|
164
|
+
/^(image|video)\//.test(file.mimetype)
|
|
165
|
+
) {
|
|
166
|
+
return this._handleMediaFile(req, file)
|
|
167
|
+
} else {
|
|
168
|
+
return this._handleFile(req, file)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_handleFile(req, file, stream = null) {
|
|
173
|
+
// Calls the original `storage._handleFile()`, wrapped in a promise:
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
if (stream) {
|
|
176
|
+
// Replace the original `file.stream` with the pass-through stream:
|
|
177
|
+
Object.defineProperty(file, 'stream', {
|
|
178
|
+
configurable: true,
|
|
179
|
+
enumerable: false,
|
|
180
|
+
value: stream
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
this.storage._handleFile(req, file, toPromiseCallback(resolve, reject))
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async _handleMediaFile(req, file) {
|
|
188
|
+
const { size, stream } = await new Promise(resolve => {
|
|
189
|
+
let data = null
|
|
190
|
+
|
|
191
|
+
const done = size => {
|
|
192
|
+
const stream = new PassThrough()
|
|
193
|
+
stream.write(data)
|
|
194
|
+
file.stream
|
|
195
|
+
.off('data', onData)
|
|
196
|
+
.off('end', onEnd)
|
|
197
|
+
.pipe(stream)
|
|
198
|
+
resolve({ size, stream })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const onEnd = () => {
|
|
202
|
+
this.app.emit('error', 'Unable to determine image size')
|
|
203
|
+
done(null)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const onData = chunk => {
|
|
207
|
+
data = data ? Buffer.concat([data, chunk]) : chunk
|
|
208
|
+
try {
|
|
209
|
+
const size = readMediaAttributes(data)
|
|
210
|
+
// On partial data, sometimes we get results back from leather without
|
|
211
|
+
// actual dimensions, so check for that.
|
|
212
|
+
if (size.mime && (size.width > 0 || size.height > 0)) {
|
|
213
|
+
done(size)
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// Ignore errors in `readMediaAttributes()` on partial data.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
file.stream
|
|
221
|
+
.on('data', onData)
|
|
222
|
+
.on('end', onEnd)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
if (size) {
|
|
226
|
+
file.width = size.width
|
|
227
|
+
file.height = size.height
|
|
228
|
+
}
|
|
229
|
+
return this._handleFile(req, file, stream)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { isNumber } from '@ditojs/utils'
|
|
2
|
+
import parseDuration from 'parse-duration'
|
|
3
|
+
|
|
4
|
+
export function getDuration(duration) {
|
|
5
|
+
return isNumber(duration) ? duration : parseDuration(duration)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function addDuration(date, duration) {
|
|
9
|
+
date.setMilliseconds(date.getMilliseconds() + getDuration(duration))
|
|
10
|
+
return date
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function subtractDuration(date, duration) {
|
|
14
|
+
return addDuration(date, -getDuration(duration))
|
|
15
|
+
}
|