@toa.io/extensions.storages 1.0.0-alpha.13 → 1.0.0-alpha.143
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 +13 -14
- package/readme.md +94 -49
- package/schemas/annotation.cos.yaml +1 -0
- package/schemas/cloudinary.cos.yaml +30 -0
- package/schemas/fs.cos.yaml +2 -0
- package/schemas/s3.cos.yaml +1 -0
- package/schemas/tmp.cos.yaml +0 -1
- package/source/Entry.ts +11 -11
- package/source/Factory.ts +16 -9
- package/source/Provider.ts +19 -19
- package/source/Scanner.ts +57 -24
- package/source/Secrets.ts +6 -0
- package/source/Storage.test.ts +103 -279
- package/source/Storage.ts +55 -204
- package/source/deployment.ts +48 -15
- package/source/errors.ts +3 -0
- package/source/index.ts +1 -1
- package/source/manifest.ts +7 -6
- package/source/providers/Cloudinary.ts +274 -0
- package/source/providers/Declaration.ts +2 -1
- package/source/providers/FileSystem.ts +79 -25
- package/source/providers/S3.ts +81 -80
- package/source/providers/Temporary.ts +2 -3
- package/source/providers/Test.ts +4 -4
- package/source/providers/index.test.ts +102 -76
- package/source/providers/index.ts +9 -4
- package/source/schemas.ts +1 -0
- package/source/test/.env.example +4 -0
- package/source/test/arny.jpg +0 -0
- package/source/test/lenna.48x48.jpeg +0 -0
- package/source/test/sample.avi +0 -0
- package/source/test/sample.svg +4 -0
- package/source/test/sample.wav +0 -0
- package/source/test/util.ts +54 -12
- package/transpiled/Entry.d.ts +12 -10
- package/transpiled/Factory.js +11 -5
- package/transpiled/Factory.js.map +1 -1
- package/transpiled/Provider.d.ts +15 -15
- package/transpiled/Provider.js +3 -3
- package/transpiled/Provider.js.map +1 -1
- package/transpiled/Scanner.d.ts +5 -2
- package/transpiled/Scanner.js +49 -22
- package/transpiled/Scanner.js.map +1 -1
- package/transpiled/Secrets.d.ts +5 -0
- package/transpiled/Secrets.js +3 -0
- package/transpiled/Secrets.js.map +1 -0
- package/transpiled/Storage.d.ts +10 -19
- package/transpiled/Storage.js +47 -153
- package/transpiled/Storage.js.map +1 -1
- package/transpiled/deployment.d.ts +1 -1
- package/transpiled/deployment.js +40 -15
- package/transpiled/deployment.js.map +1 -1
- package/transpiled/errors.d.ts +2 -0
- package/transpiled/errors.js +6 -0
- package/transpiled/errors.js.map +1 -0
- package/transpiled/index.d.ts +1 -1
- package/transpiled/manifest.js +5 -2
- package/transpiled/manifest.js.map +1 -1
- package/transpiled/providers/Cloudinary.d.ts +42 -0
- package/transpiled/providers/Cloudinary.js +189 -0
- package/transpiled/providers/Cloudinary.js.map +1 -0
- package/transpiled/providers/Declaration.d.ts +3 -2
- package/transpiled/providers/FileSystem.d.ts +15 -7
- package/transpiled/providers/FileSystem.js +64 -24
- package/transpiled/providers/FileSystem.js.map +1 -1
- package/transpiled/providers/S3.d.ts +14 -14
- package/transpiled/providers/S3.js +62 -60
- package/transpiled/providers/S3.js.map +1 -1
- package/transpiled/providers/Temporary.d.ts +1 -2
- package/transpiled/providers/Temporary.js +2 -2
- package/transpiled/providers/Temporary.js.map +1 -1
- package/transpiled/providers/Test.d.ts +3 -3
- package/transpiled/providers/Test.js +2 -2
- package/transpiled/providers/Test.js.map +1 -1
- package/transpiled/providers/index.d.ts +6 -2
- package/transpiled/providers/index.js +2 -2
- package/transpiled/providers/index.js.map +1 -1
- package/transpiled/schemas.d.ts +1 -0
- package/transpiled/schemas.js +2 -1
- package/transpiled/schemas.js.map +1 -1
- package/transpiled/test/util.d.ts +70 -5
- package/transpiled/test/util.js +43 -4
- package/transpiled/test/util.js.map +1 -1
- package/transpiled/tsconfig.tsbuildinfo +1 -1
- package/source/providers/FileSystem.test.ts +0 -5
- package/source/providers/Memory.ts +0 -41
- package/source/providers/S3.test.ts +0 -133
- package/transpiled/providers/Memory.d.ts +0 -13
- package/transpiled/providers/Memory.js +0 -60
- package/transpiled/providers/Memory.js.map +0 -1
package/source/Storage.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { decode, encode } from 'msgpackr'
|
|
4
|
-
import { buffer, newid } from '@toa.io/generic'
|
|
5
|
-
import { Err } from 'error-value'
|
|
1
|
+
import { basename, join } from 'node:path'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
6
3
|
import { Scanner } from './Scanner'
|
|
4
|
+
import type { Readable } from 'node:stream'
|
|
5
|
+
import type { Attributes, Entry, Stream } from './Entry'
|
|
7
6
|
import type { ScanOptions } from './Scanner'
|
|
8
7
|
import type { Provider } from './Provider'
|
|
9
|
-
import type { Entry } from './Entry'
|
|
10
8
|
|
|
11
9
|
export class Storage {
|
|
12
10
|
private readonly provider: Provider
|
|
@@ -17,229 +15,82 @@ export class Storage {
|
|
|
17
15
|
|
|
18
16
|
public async put (path: string, stream: Readable, options?: Options): Maybe<Entry> {
|
|
19
17
|
const scanner = new Scanner(options)
|
|
20
|
-
const pipe = stream.pipe(scanner)
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
await this.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (variant === null && rel !== '') {
|
|
45
|
-
const entry = await this.get(path)
|
|
46
|
-
|
|
47
|
-
if (entry instanceof Error)
|
|
48
|
-
return entry
|
|
18
|
+
const pipe = stream.pipe(scanner).on('error', () => undefined)
|
|
19
|
+
const id = options?.id ?? randomUUID().replace(/-/g, '')
|
|
20
|
+
const location = this.locate(path, id)
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Provider can return or throw an error.
|
|
24
|
+
* If thrown error is TYPE_MISMATCH from the Scanner, it should be returned.
|
|
25
|
+
*/
|
|
26
|
+
const error: Error | undefined = await this.provider.put(location, pipe).catch((error: any) => {
|
|
27
|
+
if (error === scanner.error) return error
|
|
28
|
+
else throw error
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
if (error instanceof Error)
|
|
32
|
+
return error
|
|
33
|
+
|
|
34
|
+
const metadata: Entry = {
|
|
35
|
+
id,
|
|
36
|
+
size: scanner.size,
|
|
37
|
+
type: scanner.type,
|
|
38
|
+
checksum: scanner.digest(),
|
|
39
|
+
created: new Date().toISOString(),
|
|
40
|
+
attributes: options?.attributes ?? {}
|
|
49
41
|
}
|
|
50
42
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
: posix.join(ENTRIES, rel, id, variant)
|
|
54
|
-
|
|
55
|
-
const stream = await this.provider.get(blob)
|
|
56
|
-
|
|
57
|
-
if (stream === null) return ERR_NOT_FOUND
|
|
58
|
-
else return stream
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
public async delete (path: string): Maybe<void> {
|
|
62
|
-
const entry = await this.get(path)
|
|
63
|
-
|
|
64
|
-
if (entry instanceof Error)
|
|
65
|
-
return entry
|
|
66
|
-
|
|
67
|
-
await this.conceal(path)
|
|
68
|
-
await this.provider.delete(posix.join(ENTRIES, path))
|
|
69
|
-
}
|
|
43
|
+
if (options?.origin !== undefined)
|
|
44
|
+
metadata.attributes.origin = options.origin
|
|
70
45
|
|
|
71
|
-
|
|
72
|
-
const listfile = posix.join(ENTRIES, path, LIST)
|
|
73
|
-
const stream = await this.provider.get(listfile)
|
|
46
|
+
await this.provider.commit(location, metadata)
|
|
74
47
|
|
|
75
|
-
return
|
|
48
|
+
return metadata
|
|
76
49
|
}
|
|
77
50
|
|
|
78
|
-
public async
|
|
79
|
-
const
|
|
80
|
-
const dir = posix.join(ENTRIES, path)
|
|
81
|
-
const list = await this.list(path)
|
|
82
|
-
|
|
83
|
-
if (list.length !== ids.length || unique.size !== ids.length)
|
|
84
|
-
return ERR_PERMUTATION_MISMATCH
|
|
85
|
-
|
|
86
|
-
for (const id of ids)
|
|
87
|
-
if (!list.includes(id))
|
|
88
|
-
return ERR_PERMUTATION_MISMATCH
|
|
51
|
+
public async get (path: string): Maybe<Stream> {
|
|
52
|
+
const location = this.locate(path)
|
|
89
53
|
|
|
90
|
-
await this.provider.
|
|
54
|
+
return await this.provider.get(location)
|
|
91
55
|
}
|
|
92
56
|
|
|
93
|
-
public async
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const index = list.indexOf(id)
|
|
98
|
-
|
|
99
|
-
if (index === -1)
|
|
100
|
-
return ERR_NOT_FOUND
|
|
101
|
-
|
|
102
|
-
list.splice(index, 1)
|
|
103
|
-
|
|
104
|
-
await this.provider.put(dir, LIST, Readable.from(encode(list)))
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
public async reveal (path: string): Maybe<void> {
|
|
108
|
-
const { id, rel } = this.parse(path)
|
|
109
|
-
|
|
110
|
-
return await this.enroll(rel, id)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
public async diversify (path: string, name: string, stream: Readable): Maybe<void> {
|
|
114
|
-
const scanner = new Scanner()
|
|
115
|
-
const pipe = stream.pipe(scanner)
|
|
116
|
-
|
|
117
|
-
await this.provider.put(posix.join(ENTRIES, path), name, pipe)
|
|
118
|
-
|
|
119
|
-
if (scanner.error !== null)
|
|
120
|
-
return scanner.error
|
|
121
|
-
|
|
122
|
-
const { size, type } = scanner
|
|
123
|
-
const entry = await this.get(path)
|
|
124
|
-
|
|
125
|
-
if (entry instanceof Error)
|
|
126
|
-
return entry
|
|
57
|
+
public async head (path: string): Promise<Maybe<Entry>> {
|
|
58
|
+
const id = basename(path)
|
|
59
|
+
const location = this.locate(path)
|
|
60
|
+
const metadata = await this.provider.head(location)
|
|
127
61
|
|
|
128
|
-
|
|
129
|
-
|
|
62
|
+
if (metadata instanceof Error)
|
|
63
|
+
return metadata
|
|
130
64
|
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
public async annotate (path: string, key: string, value?: unknown): Maybe<void> {
|
|
135
|
-
const entry = await this.get(path)
|
|
136
|
-
|
|
137
|
-
if (entry instanceof Error)
|
|
138
|
-
return entry
|
|
139
|
-
|
|
140
|
-
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
141
|
-
if (value === undefined) delete entry.meta[key]
|
|
142
|
-
else entry.meta[key] = value
|
|
143
|
-
|
|
144
|
-
await this.save(path, entry)
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
private async transit (stream: Readable): Promise<string> {
|
|
148
|
-
const tempname = newid()
|
|
149
|
-
|
|
150
|
-
await this.provider.put(TEMP, tempname, stream)
|
|
151
|
-
|
|
152
|
-
return tempname
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
private async persist (tempname: string, id: string): Promise<void> {
|
|
156
|
-
const temp = posix.join(TEMP, tempname)
|
|
157
|
-
const blob = posix.join(BLOBs, id)
|
|
158
|
-
|
|
159
|
-
await this.provider.move(temp, blob)
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// eslint-disable-next-line max-params
|
|
163
|
-
private async create
|
|
164
|
-
(path: string, id: string, size: number, type: string, meta: Meta = {}): Promise<Entry> {
|
|
165
|
-
const entry: Entry = {
|
|
65
|
+
return {
|
|
166
66
|
id,
|
|
167
|
-
|
|
168
|
-
type,
|
|
169
|
-
created: Date.now(),
|
|
170
|
-
variants: [],
|
|
171
|
-
meta
|
|
67
|
+
...metadata
|
|
172
68
|
}
|
|
173
|
-
|
|
174
|
-
const metafile = posix.join(path, entry.id)
|
|
175
|
-
const existing = await this.get(metafile)
|
|
176
|
-
|
|
177
|
-
if (existing instanceof Error)
|
|
178
|
-
await this.save(metafile, entry)
|
|
179
|
-
|
|
180
|
-
await this.enroll(path, id, true)
|
|
181
|
-
|
|
182
|
-
return entry
|
|
183
69
|
}
|
|
184
70
|
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
const stream = Readable.from(buffer)
|
|
71
|
+
public async delete (path: string): Maybe<void> {
|
|
72
|
+
const location = this.locate(path)
|
|
188
73
|
|
|
189
|
-
|
|
74
|
+
return this.provider.delete(location)
|
|
190
75
|
}
|
|
191
76
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const list = await this.list(path)
|
|
195
|
-
const index = list.indexOf(id)
|
|
196
|
-
|
|
197
|
-
if (index !== -1)
|
|
198
|
-
if (addition) list.splice(index, 1)
|
|
199
|
-
else return
|
|
200
|
-
else if (!addition) {
|
|
201
|
-
const entry = await this.get(posix.join(path, id))
|
|
202
|
-
|
|
203
|
-
if (entry instanceof Error)
|
|
204
|
-
return entry
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
list.push(id)
|
|
208
|
-
|
|
209
|
-
await this.provider.put(dir, LIST, Readable.from(encode(list)))
|
|
77
|
+
public path (): string | null {
|
|
78
|
+
return this.provider.root ?? null
|
|
210
79
|
}
|
|
211
80
|
|
|
212
|
-
private
|
|
213
|
-
|
|
214
|
-
const [id, ...rest] = last.split('.')
|
|
215
|
-
const variant = rest.length > 0 ? rest.join('.') : null
|
|
216
|
-
const rel = segments.reverse().join('/')
|
|
217
|
-
|
|
218
|
-
return { rel, id, variant }
|
|
81
|
+
private locate (...rel: string[]): string {
|
|
82
|
+
return join(ENTRIES, ...rel)
|
|
219
83
|
}
|
|
220
84
|
}
|
|
221
85
|
|
|
222
|
-
const
|
|
223
|
-
const ERR_PERMUTATION_MISMATCH = Err('PERMUTATION_MISMATCH')
|
|
224
|
-
|
|
225
|
-
const TEMP = '/temp'
|
|
226
|
-
const BLOBs = '/blobs'
|
|
227
|
-
const ENTRIES = '/entries'
|
|
228
|
-
const LIST = '.list'
|
|
229
|
-
const META = '.meta'
|
|
230
|
-
|
|
231
|
-
type Maybe<T> = Promise<T | Error>
|
|
232
|
-
|
|
233
|
-
interface Path {
|
|
234
|
-
rel: string
|
|
235
|
-
id: string
|
|
236
|
-
variant: string | null
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
type Meta = Record<string, string>
|
|
86
|
+
const ENTRIES = '/'
|
|
240
87
|
|
|
241
88
|
interface Options extends ScanOptions {
|
|
242
|
-
|
|
89
|
+
id?: string
|
|
90
|
+
origin?: string
|
|
91
|
+
attributes?: Attributes
|
|
243
92
|
}
|
|
244
93
|
|
|
94
|
+
type Maybe<T> = Promise<T | Error>
|
|
95
|
+
|
|
245
96
|
export type Storages = Record<string, Storage>
|
package/source/deployment.ts
CHANGED
|
@@ -3,21 +3,25 @@ import { encode } from '@toa.io/generic'
|
|
|
3
3
|
import { providers } from './providers'
|
|
4
4
|
import { validateAnnotation } from './Annotation'
|
|
5
5
|
import type { Annotation } from './Annotation'
|
|
6
|
-
import type { Dependency, Variable } from '@toa.io/operations'
|
|
6
|
+
import type { Dependency, Variable, Mounts } from '@toa.io/operations'
|
|
7
7
|
import type { context } from '@toa.io/norm'
|
|
8
8
|
|
|
9
|
-
export const
|
|
9
|
+
export const ENV_PREFIX = 'TOA_STORAGES'
|
|
10
10
|
|
|
11
11
|
export function deployment (instances: Instance[], annotation: unknown): Dependency {
|
|
12
12
|
validate(instances, annotation)
|
|
13
13
|
|
|
14
14
|
const value = encode(annotation)
|
|
15
|
-
const pointer: Variable = { name:
|
|
15
|
+
const pointer: Variable = { name: ENV_PREFIX, value }
|
|
16
16
|
const secrets = getSecrets(annotation)
|
|
17
|
+
const mounts = getMounts(instances, annotation)
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
const dependency: Dependency = { variables: { global: [pointer, ...secrets] } }
|
|
20
|
+
|
|
21
|
+
if (mounts !== null)
|
|
22
|
+
dependency.mounts = mounts
|
|
23
|
+
|
|
24
|
+
return dependency
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
function validate (instances: Instance[], annotation: unknown): asserts annotation is Annotation {
|
|
@@ -40,18 +44,47 @@ function getSecrets (annotation: Annotation): Variable[] {
|
|
|
40
44
|
for (const [name, declaration] of Object.entries(annotation)) {
|
|
41
45
|
const Provider = providers[declaration.provider]
|
|
42
46
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
name:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
if (Provider.SECRETS !== undefined)
|
|
48
|
+
// eslint-disable-next-line max-depth
|
|
49
|
+
for (const secret of Provider.SECRETS)
|
|
50
|
+
secrets.push({
|
|
51
|
+
name: `${ENV_PREFIX}_${name}_${secret.name}`.toUpperCase(),
|
|
52
|
+
secret: {
|
|
53
|
+
name: `toa-storages-${name}`,
|
|
54
|
+
key: secret.name,
|
|
55
|
+
optional: secret.optional
|
|
56
|
+
}
|
|
57
|
+
})
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
return secrets
|
|
55
61
|
}
|
|
56
62
|
|
|
63
|
+
function getMounts (instances: Instance[], annotation: Annotation): Mounts | null {
|
|
64
|
+
let mounts: Mounts | null = null
|
|
65
|
+
|
|
66
|
+
for (const { locator, manifest } of instances)
|
|
67
|
+
for (const name of manifest) {
|
|
68
|
+
const declaration = annotation[name]
|
|
69
|
+
|
|
70
|
+
// eslint-disable-next-line max-depth
|
|
71
|
+
if (declaration.provider !== 'fs')
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
// eslint-disable-next-line max-depth
|
|
75
|
+
if (declaration.claim !== undefined) {
|
|
76
|
+
mounts ??= {}
|
|
77
|
+
mounts[locator.label] ??= []
|
|
78
|
+
|
|
79
|
+
mounts[locator.label].push({
|
|
80
|
+
name,
|
|
81
|
+
path: declaration.path,
|
|
82
|
+
claim: declaration.claim
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return mounts
|
|
88
|
+
}
|
|
89
|
+
|
|
57
90
|
export type Instance = context.Dependency<string[]>
|
package/source/errors.ts
ADDED
package/source/index.ts
CHANGED
package/source/manifest.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { match } from 'matchacho'
|
|
2
|
-
|
|
3
1
|
export function manifest (manifest: string | string[] | null): string[] {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
if (manifest === null)
|
|
3
|
+
return []
|
|
4
|
+
|
|
5
|
+
if (typeof manifest === 'string')
|
|
6
|
+
return [manifest]
|
|
7
|
+
|
|
8
|
+
return manifest
|
|
8
9
|
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { basename, dirname, join } from 'node:path'
|
|
2
|
+
import { Readable } from 'node:stream'
|
|
3
|
+
import { v2 as cloudinary } from 'cloudinary'
|
|
4
|
+
import { console } from 'openspan'
|
|
5
|
+
import { Provider } from '../Provider'
|
|
6
|
+
import { ERR_NOT_FOUND } from '../errors'
|
|
7
|
+
import type { Maybe } from '@toa.io/types'
|
|
8
|
+
import type { Metadata, Stream } from '../Entry'
|
|
9
|
+
import type { Secret, Secrets } from '../Secrets'
|
|
10
|
+
import type { ReadableStream } from 'node:stream/web'
|
|
11
|
+
import type {
|
|
12
|
+
ConfigOptions,
|
|
13
|
+
ImageTransformationOptions,
|
|
14
|
+
VideoTransformationOptions
|
|
15
|
+
} from 'cloudinary'
|
|
16
|
+
|
|
17
|
+
export type CloudinarySecrets = Secrets<'API_KEY' | 'API_SECRET'>
|
|
18
|
+
|
|
19
|
+
export class Cloudinary extends Provider<CloudinaryOptions> {
|
|
20
|
+
public static override readonly SECRETS: readonly Secret[] = [
|
|
21
|
+
{ name: 'API_KEY' },
|
|
22
|
+
{ name: 'API_SECRET' }
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
private readonly type: StorageType
|
|
26
|
+
private readonly transformations: Transformation[] = []
|
|
27
|
+
private readonly config: ConfigOptions
|
|
28
|
+
private readonly prefix: string
|
|
29
|
+
|
|
30
|
+
public constructor (options: CloudinaryOptions, secrets?: CloudinarySecrets) {
|
|
31
|
+
super(options, secrets)
|
|
32
|
+
|
|
33
|
+
this.type = options.type
|
|
34
|
+
|
|
35
|
+
if (options.transformations !== undefined)
|
|
36
|
+
this.transformations = options.transformations.map((transformation) => {
|
|
37
|
+
const { extension, ...rest } = transformation
|
|
38
|
+
|
|
39
|
+
let expression = extension
|
|
40
|
+
|
|
41
|
+
if (!extension.startsWith('^'))
|
|
42
|
+
expression = '^' + extension
|
|
43
|
+
|
|
44
|
+
if (!extension.endsWith('$'))
|
|
45
|
+
expression = extension + '$'
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
extension: new RegExp(expression),
|
|
49
|
+
...rest
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
this.config = {
|
|
54
|
+
cloud_name: options.environment,
|
|
55
|
+
api_key: secrets!.API_KEY,
|
|
56
|
+
api_secret: secrets!.API_SECRET
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
this.prefix = options.prefix ?? '/'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public async get (path: string): Promise<Maybe<Stream>> {
|
|
63
|
+
const response = await this.fetch(path)
|
|
64
|
+
|
|
65
|
+
if (response instanceof Error)
|
|
66
|
+
return ERR_NOT_FOUND
|
|
67
|
+
|
|
68
|
+
const metadata = this.metadata(response)
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
stream: Readable.fromWeb(response.body as ReadableStream),
|
|
72
|
+
...metadata
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public async head (path: string): Promise<Maybe<Metadata>> {
|
|
77
|
+
const response = await this.fetch(path, 'HEAD')
|
|
78
|
+
|
|
79
|
+
if (response instanceof Error)
|
|
80
|
+
return ERR_NOT_FOUND
|
|
81
|
+
|
|
82
|
+
return this.metadata(response)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public async put (path: string, stream: Readable): Promise<void> {
|
|
86
|
+
const id = basename(path)
|
|
87
|
+
const folder = join(this.prefix, dirname(path))
|
|
88
|
+
|
|
89
|
+
console.debug('Uploading to Cloudinary', { path })
|
|
90
|
+
|
|
91
|
+
await new Promise((resolve, reject) => {
|
|
92
|
+
stream.pipe(this.cloudinary().uploader.upload_stream({
|
|
93
|
+
public_id: id,
|
|
94
|
+
folder,
|
|
95
|
+
resource_type: 'auto',
|
|
96
|
+
overwrite: true,
|
|
97
|
+
invalidate: true
|
|
98
|
+
},
|
|
99
|
+
(error, result) => {
|
|
100
|
+
if (error !== undefined) reject(error)
|
|
101
|
+
else resolve(result)
|
|
102
|
+
}))
|
|
103
|
+
|
|
104
|
+
stream.on('error', reject)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public async commit (): Promise<void> {
|
|
109
|
+
// metadata is read-only
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public async delete (path: string): Promise<void> {
|
|
113
|
+
const id = join(this.prefix, path)
|
|
114
|
+
|
|
115
|
+
console.debug('Deleting from Cloudinary', { path: id })
|
|
116
|
+
|
|
117
|
+
await this.cloudinary().uploader.destroy(id,
|
|
118
|
+
{ resource_type: this.type, invalidate: true })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
public async move (from: string, to: string): Promise<void | Error> {
|
|
122
|
+
const source = join(this.prefix, from)
|
|
123
|
+
const target = join(this.prefix, to)
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await this.cloudinary().uploader.rename(source, target,
|
|
127
|
+
{ resource_type: this.type, overwrite: true })
|
|
128
|
+
} catch (error: any) {
|
|
129
|
+
if (error.http_code === 404)
|
|
130
|
+
return ERR_NOT_FOUND
|
|
131
|
+
else
|
|
132
|
+
throw error
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async fetch (path: string, method = 'GET'): Promise<Maybe<Response>> {
|
|
137
|
+
const url = this.url(path)
|
|
138
|
+
|
|
139
|
+
if (url === null)
|
|
140
|
+
return ERR_NOT_FOUND
|
|
141
|
+
|
|
142
|
+
console.debug('Fetching from Cloudinary', {
|
|
143
|
+
method,
|
|
144
|
+
path,
|
|
145
|
+
url
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const response = await fetch(url, { method }).catch((e) => e)
|
|
149
|
+
|
|
150
|
+
if (response instanceof Error || response.ok === false) {
|
|
151
|
+
console.debug('Failed to fetch from Cloudinary', {
|
|
152
|
+
url,
|
|
153
|
+
message: response.message
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
return ERR_NOT_FOUND
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return response
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private url (path: string): string | null {
|
|
163
|
+
const [base, transformation] = this.toTransformation(path)
|
|
164
|
+
|
|
165
|
+
if (base === null)
|
|
166
|
+
return null
|
|
167
|
+
|
|
168
|
+
const id = join(this.prefix, base)
|
|
169
|
+
|
|
170
|
+
return this.cloudinary().url(id, {
|
|
171
|
+
resource_type: this.type,
|
|
172
|
+
transformation,
|
|
173
|
+
version: 2
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private toTransformation (path: string): [string | null, TransformationOptions[] | undefined] {
|
|
178
|
+
if (this.transformations.length === 0)
|
|
179
|
+
return [path, undefined]
|
|
180
|
+
|
|
181
|
+
const [base, ...extensions] = path.split('.')
|
|
182
|
+
const transformations: TransformationOptions[] = []
|
|
183
|
+
|
|
184
|
+
let t = 0
|
|
185
|
+
|
|
186
|
+
for (const extension of extensions) {
|
|
187
|
+
let found = false
|
|
188
|
+
|
|
189
|
+
for (t; t < this.transformations.length && !found; t++) {
|
|
190
|
+
const { extension: regex, transformation: options, optional } = this.transformations[t]
|
|
191
|
+
|
|
192
|
+
const match = regex.exec(extension)
|
|
193
|
+
|
|
194
|
+
// eslint-disable-next-line max-depth
|
|
195
|
+
if (match === null)
|
|
196
|
+
if (optional === true)
|
|
197
|
+
continue
|
|
198
|
+
else
|
|
199
|
+
return [null, undefined]
|
|
200
|
+
|
|
201
|
+
const transformation = Object.fromEntries(Object.entries(options).map(([key, value]) => {
|
|
202
|
+
if (typeof value !== 'string')
|
|
203
|
+
return [key, value]
|
|
204
|
+
|
|
205
|
+
if (value.startsWith('<') && value.endsWith('>'))
|
|
206
|
+
value = match.groups![value.slice(1, -1)]
|
|
207
|
+
|
|
208
|
+
if (key === 'zoom' && value !== undefined)
|
|
209
|
+
value = Number.parseInt(value as string) / 100
|
|
210
|
+
|
|
211
|
+
if (key === 'fetch_format' && value === 'jpeg')
|
|
212
|
+
value = 'jpg'
|
|
213
|
+
|
|
214
|
+
return [key, value]
|
|
215
|
+
}))
|
|
216
|
+
|
|
217
|
+
transformations.push(transformation)
|
|
218
|
+
found = true
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!found)
|
|
222
|
+
return [null, undefined]
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (t; t < this.transformations.length; t++)
|
|
226
|
+
if (this.transformations[t].optional !== true)
|
|
227
|
+
return [null, undefined]
|
|
228
|
+
|
|
229
|
+
return [base, transformations]
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private metadata (response: Response): Metadata {
|
|
233
|
+
const size = response.headers.get('content-length') === null
|
|
234
|
+
? 0
|
|
235
|
+
: Number.parseInt(response.headers.get('content-length')!)
|
|
236
|
+
|
|
237
|
+
const created = response.headers.get('date') ?? new Date().toISOString()
|
|
238
|
+
const etag = response.headers.get('etag')
|
|
239
|
+
const checksum = etag === null ? basename(response.url) : etag.slice(1, -1)
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
type: response.headers.get('content-type')!,
|
|
243
|
+
size,
|
|
244
|
+
checksum,
|
|
245
|
+
created,
|
|
246
|
+
attributes: {}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private cloudinary (): typeof cloudinary {
|
|
251
|
+
cloudinary.config(this.config)
|
|
252
|
+
|
|
253
|
+
return cloudinary
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface CloudinaryOptions {
|
|
258
|
+
environment: string
|
|
259
|
+
type: StorageType
|
|
260
|
+
prefix?: string
|
|
261
|
+
transformations?: TransformationDeclaration[]
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
type TransformationDeclaration = Omit<Transformation, 'extension'> & { extension: string }
|
|
265
|
+
|
|
266
|
+
interface Transformation {
|
|
267
|
+
extension: RegExp
|
|
268
|
+
transformation: object
|
|
269
|
+
optional?: boolean
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
type TransformationOptions = ImageTransformationOptions | VideoTransformationOptions
|
|
273
|
+
|
|
274
|
+
type StorageType = 'image' | 'video'
|