@toa.io/extensions.storages 1.0.0-alpha.21 → 1.0.0-alpha.219
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/readme.md +96 -51
- package/schemas/annotation.cos.yaml +1 -0
- package/schemas/cloudinary.cos.yaml +37 -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 +13 -11
- package/source/Factory.ts +16 -9
- package/source/Provider.ts +22 -19
- package/source/Scanner.ts +58 -26
- package/source/Secrets.ts +6 -0
- package/source/Storage.test.ts +118 -274
- package/source/Storage.ts +62 -207
- package/source/deployment.ts +52 -16
- package/source/errors.ts +3 -0
- package/source/index.ts +3 -1
- package/source/manifest.ts +7 -6
- package/source/providers/Cloudinary.ts +320 -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 +10 -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/plank.mp4 +0 -0
- package/source/test/sample.avi +0 -0
- package/source/test/sample.wav +0 -0
- package/source/test/sport.mp4 +0 -0
- package/source/test/util.ts +62 -12
- package/transpiled/Entry.d.ts +15 -11
- package/transpiled/Factory.js +11 -5
- package/transpiled/Factory.js.map +1 -1
- package/transpiled/Provider.d.ts +17 -16
- package/transpiled/Provider.js +6 -4
- package/transpiled/Provider.js.map +1 -1
- package/transpiled/Scanner.d.ts +6 -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 +13 -21
- package/transpiled/Storage.js +52 -155
- package/transpiled/Storage.js.map +1 -1
- package/transpiled/deployment.d.ts +1 -1
- package/transpiled/deployment.js +43 -16
- 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 +3 -1
- package/transpiled/manifest.js +5 -2
- package/transpiled/manifest.js.map +1 -1
- package/transpiled/providers/Cloudinary.d.ts +51 -0
- package/transpiled/providers/Cloudinary.js +223 -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 +7 -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 +89 -5
- package/transpiled/test/util.js +51 -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
|
@@ -0,0 +1,320 @@
|
|
|
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
|
+
TransformationOptions,
|
|
14
|
+
UploadApiOptions
|
|
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 eager: TransformationOptions[] = []
|
|
27
|
+
private readonly transformations: Transformation[] = []
|
|
28
|
+
private readonly config: ConfigOptions
|
|
29
|
+
private readonly prefix: string
|
|
30
|
+
|
|
31
|
+
public constructor (options: CloudinaryOptions, secrets?: CloudinarySecrets) {
|
|
32
|
+
super(options, secrets)
|
|
33
|
+
|
|
34
|
+
this.type = options.type
|
|
35
|
+
|
|
36
|
+
if (options.eager !== undefined)
|
|
37
|
+
this.eager = options.eager
|
|
38
|
+
|
|
39
|
+
if (options.transformations !== undefined)
|
|
40
|
+
this.transformations = options.transformations.map((transformation) => {
|
|
41
|
+
const { extension, ...rest } = transformation
|
|
42
|
+
|
|
43
|
+
let expression = extension
|
|
44
|
+
|
|
45
|
+
if (!extension.startsWith('^'))
|
|
46
|
+
expression = '^' + extension
|
|
47
|
+
|
|
48
|
+
if (!extension.endsWith('$'))
|
|
49
|
+
expression = extension + '$'
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
extension: new RegExp(expression),
|
|
53
|
+
...rest
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
this.config = {
|
|
58
|
+
cloud_name: options.environment,
|
|
59
|
+
api_key: secrets!.API_KEY,
|
|
60
|
+
api_secret: secrets!.API_SECRET
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.prefix = options.prefix ?? '/'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public async get (path: string, options?: GetOptions): Promise<Maybe<Stream>> {
|
|
67
|
+
const headers: Record<string, string> = {}
|
|
68
|
+
|
|
69
|
+
if (options?.range !== undefined)
|
|
70
|
+
headers.range = options.range
|
|
71
|
+
|
|
72
|
+
if (options?.agent !== undefined)
|
|
73
|
+
headers['user-agent'] = options.agent
|
|
74
|
+
|
|
75
|
+
const response = await this.fetch(path, { method: 'GET', headers })
|
|
76
|
+
|
|
77
|
+
if (response instanceof Error)
|
|
78
|
+
return ERR_NOT_FOUND
|
|
79
|
+
|
|
80
|
+
const metadata = this.metadata(response)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
stream: Readable.fromWeb(response.body as ReadableStream),
|
|
84
|
+
...metadata
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public async head (path: string): Promise<Maybe<Metadata>> {
|
|
89
|
+
const response = await this.fetch(path, { method: 'HEAD' })
|
|
90
|
+
|
|
91
|
+
if (response instanceof Error)
|
|
92
|
+
return ERR_NOT_FOUND
|
|
93
|
+
|
|
94
|
+
return this.metadata(response)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public async put (path: string, stream: Readable): Promise<void> {
|
|
98
|
+
const id = basename(path)
|
|
99
|
+
const folder = join(this.prefix, dirname(path))
|
|
100
|
+
|
|
101
|
+
await new Promise((resolve, reject) => {
|
|
102
|
+
const options: UploadApiOptions = {
|
|
103
|
+
public_id: id,
|
|
104
|
+
folder,
|
|
105
|
+
eager: this.eager,
|
|
106
|
+
resource_type: this.type
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.debug('Uploading to Cloudinary', { path, options })
|
|
110
|
+
|
|
111
|
+
stream.pipe(this.cloudinary().uploader.upload_stream(options,
|
|
112
|
+
(error, result) => {
|
|
113
|
+
if (error !== undefined) reject(error)
|
|
114
|
+
else resolve(result)
|
|
115
|
+
}))
|
|
116
|
+
|
|
117
|
+
stream.on('error', (e) => {
|
|
118
|
+
console.error('Cloudinary stream error', { path, error: e })
|
|
119
|
+
stream.destroy()
|
|
120
|
+
|
|
121
|
+
reject(e)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public async commit (): Promise<void> {
|
|
127
|
+
// metadata is read-only
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public async delete (path: string): Promise<void> {
|
|
131
|
+
const id = join(this.prefix, path)
|
|
132
|
+
|
|
133
|
+
console.debug('Deleting from Cloudinary', { path: id })
|
|
134
|
+
|
|
135
|
+
await this.cloudinary().uploader.destroy(id,
|
|
136
|
+
{ resource_type: this.type, invalidate: true })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
public async move (from: string, to: string): Promise<void | Error> {
|
|
140
|
+
const source = join(this.prefix, from)
|
|
141
|
+
const target = join(this.prefix, to)
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await this.cloudinary().uploader.rename(source, target,
|
|
145
|
+
{ resource_type: this.type, overwrite: true })
|
|
146
|
+
} catch (error: any) {
|
|
147
|
+
if (error.http_code === 404)
|
|
148
|
+
return ERR_NOT_FOUND
|
|
149
|
+
else
|
|
150
|
+
throw error
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async fetch (path: string, options: RequestInit = { method: 'GET' }): Promise<Maybe<Response>> {
|
|
155
|
+
const url = this.url(path)
|
|
156
|
+
|
|
157
|
+
if (url === null)
|
|
158
|
+
return ERR_NOT_FOUND
|
|
159
|
+
|
|
160
|
+
console.debug('Fetching from Cloudinary', {
|
|
161
|
+
method: options.method,
|
|
162
|
+
path,
|
|
163
|
+
url
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const response = await fetch(url, options).catch((e) => e)
|
|
167
|
+
|
|
168
|
+
if (response instanceof Error || response.ok === false) {
|
|
169
|
+
console.debug('Failed to fetch from Cloudinary', {
|
|
170
|
+
url,
|
|
171
|
+
status: response.status,
|
|
172
|
+
message: response.message
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
return ERR_NOT_FOUND
|
|
176
|
+
} else
|
|
177
|
+
console.debug('Received response from Cloudinary', {
|
|
178
|
+
url,
|
|
179
|
+
status: response.status,
|
|
180
|
+
headers: response.headers
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return response
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private url (path: string): string | null {
|
|
187
|
+
const [base, transformation] = this.toTransformation(path)
|
|
188
|
+
|
|
189
|
+
if (base === null)
|
|
190
|
+
return null
|
|
191
|
+
|
|
192
|
+
const id = join(this.prefix, base)
|
|
193
|
+
|
|
194
|
+
return this.cloudinary().url(id, {
|
|
195
|
+
resource_type: this.type,
|
|
196
|
+
transformation,
|
|
197
|
+
version: 2
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private toTransformation (path: string): [string | null, TransformationOptions[] | undefined] {
|
|
202
|
+
if (this.transformations.length === 0)
|
|
203
|
+
return [path, undefined]
|
|
204
|
+
|
|
205
|
+
const [base, ...extensions] = path.split('.')
|
|
206
|
+
const transformations: TransformationOptions[] = []
|
|
207
|
+
|
|
208
|
+
let t = 0
|
|
209
|
+
|
|
210
|
+
for (const extension of extensions) {
|
|
211
|
+
let found = false
|
|
212
|
+
|
|
213
|
+
for (t; t < this.transformations.length && !found; t++) {
|
|
214
|
+
const { extension: regex, condition, transformation, optional } = this.transformations[t]
|
|
215
|
+
|
|
216
|
+
const match = regex.exec(extension)
|
|
217
|
+
|
|
218
|
+
// eslint-disable-next-line max-depth
|
|
219
|
+
if (match === null)
|
|
220
|
+
if (optional === true)
|
|
221
|
+
continue
|
|
222
|
+
else
|
|
223
|
+
return [null, undefined]
|
|
224
|
+
|
|
225
|
+
const options = Array.isArray(transformation) ? transformation : [transformation]
|
|
226
|
+
const stages = options.map((stage) => this.mapTransformation(stage, match.groups!))
|
|
227
|
+
|
|
228
|
+
found = true
|
|
229
|
+
|
|
230
|
+
if (condition === undefined)
|
|
231
|
+
transformations.push(...stages)
|
|
232
|
+
else {
|
|
233
|
+
transformations.push({ if: condition })
|
|
234
|
+
transformations.push(...stages)
|
|
235
|
+
transformations.push({ if: 'end' })
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!found)
|
|
240
|
+
return [null, undefined]
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (t; t < this.transformations.length; t++)
|
|
244
|
+
if (this.transformations[t].optional !== true)
|
|
245
|
+
return [null, undefined]
|
|
246
|
+
|
|
247
|
+
return [base, transformations]
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private mapTransformation (options: Record<string, unknown>, groups: Record<string, string>): TransformationOptions {
|
|
251
|
+
return Object.fromEntries(Object.entries(options).map(([key, value]) => {
|
|
252
|
+
if (typeof value !== 'string')
|
|
253
|
+
return [key, value]
|
|
254
|
+
|
|
255
|
+
if (value.startsWith('<') && value.endsWith('>'))
|
|
256
|
+
value = groups[value.slice(1, -1)]
|
|
257
|
+
|
|
258
|
+
if (key === 'zoom' && value !== undefined)
|
|
259
|
+
value = Number.parseInt(value as string) / 100
|
|
260
|
+
|
|
261
|
+
if (key === 'fetch_format' && value === 'jpeg')
|
|
262
|
+
value = 'jpg'
|
|
263
|
+
|
|
264
|
+
return [key, value]
|
|
265
|
+
}))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private metadata (response: Response): Metadata {
|
|
269
|
+
const size = response.headers.get('content-length') === null
|
|
270
|
+
? null
|
|
271
|
+
: Number.parseInt(response.headers.get('content-length')!)
|
|
272
|
+
|
|
273
|
+
const created = response.headers.get('date') ?? new Date().toISOString()
|
|
274
|
+
const etag = response.headers.get('etag')
|
|
275
|
+
const checksum = etag === null ? basename(response.url) : etag.slice(1, -1)
|
|
276
|
+
const range = response.headers.get('content-range')
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
type: response.headers.get('content-type')!,
|
|
280
|
+
size,
|
|
281
|
+
checksum,
|
|
282
|
+
created,
|
|
283
|
+
range: range ?? undefined,
|
|
284
|
+
partial: response.status === 206,
|
|
285
|
+
attributes: {
|
|
286
|
+
url: response.url
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private cloudinary (): typeof cloudinary {
|
|
292
|
+
cloudinary.config(this.config)
|
|
293
|
+
|
|
294
|
+
return cloudinary
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
interface GetOptions {
|
|
299
|
+
agent?: string
|
|
300
|
+
range?: string
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export interface CloudinaryOptions {
|
|
304
|
+
environment: string
|
|
305
|
+
type: StorageType
|
|
306
|
+
prefix?: string
|
|
307
|
+
eager?: TransformationOptions[]
|
|
308
|
+
transformations?: TransformationDeclaration[]
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
type TransformationDeclaration = Omit<Transformation, 'extension'> & { extension: string }
|
|
312
|
+
|
|
313
|
+
interface Transformation {
|
|
314
|
+
extension: RegExp
|
|
315
|
+
condition?: string
|
|
316
|
+
transformation: Record<string, unknown> | Array<Record<string, unknown>>
|
|
317
|
+
optional?: boolean
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
type StorageType = 'image' | 'video'
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { S3Options } from './S3'
|
|
2
|
+
import type { CloudinaryOptions } from './Cloudinary'
|
|
2
3
|
import type { FileSystemOptions } from './FileSystem'
|
|
3
4
|
import type { TemporaryOptions } from './Temporary'
|
|
4
5
|
|
|
5
6
|
export type Declaration =
|
|
6
7
|
({ provider: 's3' } & S3Options)
|
|
8
|
+
| ({ provider: 'cloudinary' } & CloudinaryOptions)
|
|
7
9
|
| ({ provider: 'fs' } & FileSystemOptions)
|
|
8
10
|
| ({ provider: 'tmp' } & TemporaryOptions)
|
|
9
|
-
| ({ provider: 'mem' })
|
|
10
11
|
| ({ provider: 'test' } & TemporaryOptions)
|
|
@@ -1,51 +1,105 @@
|
|
|
1
|
-
import { type Readable } from 'node:stream'
|
|
2
|
-
import { dirname, join } from 'node:path'
|
|
3
1
|
import fs from 'node:fs/promises'
|
|
2
|
+
import { createReadStream } from 'node:fs'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
4
|
import { Provider } from '../Provider'
|
|
5
|
-
import
|
|
5
|
+
import { ERR_NOT_FOUND } from '../errors'
|
|
6
|
+
import type { Readable } from 'node:stream'
|
|
7
|
+
import type { Maybe } from '@toa.io/types'
|
|
8
|
+
import type { Metadata, Stream } from '../Entry'
|
|
6
9
|
|
|
7
10
|
export interface FileSystemOptions {
|
|
8
11
|
path: string
|
|
12
|
+
claim?: string
|
|
9
13
|
}
|
|
10
14
|
|
|
11
15
|
export class FileSystem extends Provider<FileSystemOptions> {
|
|
12
|
-
|
|
16
|
+
public override readonly root: string
|
|
13
17
|
|
|
14
|
-
public constructor (options: FileSystemOptions
|
|
15
|
-
super(options
|
|
18
|
+
public constructor (options: FileSystemOptions) {
|
|
19
|
+
super(options)
|
|
16
20
|
|
|
17
|
-
this.
|
|
21
|
+
this.root = options.path
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
public async get (
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
public async get (rel: string): Promise<Maybe<Stream>> {
|
|
25
|
+
const path = this.blob(rel)
|
|
26
|
+
const metadata = await this.head(rel)
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return null
|
|
28
|
+
if (metadata instanceof Error)
|
|
29
|
+
return metadata
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
const stream = createReadStream(path)
|
|
32
|
+
|
|
33
|
+
return { stream, ...metadata }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public async head (rel: string): Promise<Maybe<Metadata>> {
|
|
37
|
+
const path = this.meta(rel)
|
|
38
|
+
|
|
39
|
+
return this.try(async () => {
|
|
40
|
+
const contents = await fs.readFile(path, 'utf8')
|
|
41
|
+
|
|
42
|
+
return JSON.parse(contents)
|
|
43
|
+
})
|
|
30
44
|
}
|
|
31
45
|
|
|
32
|
-
public async put (rel: string,
|
|
33
|
-
const
|
|
34
|
-
const
|
|
46
|
+
public async put (rel: string, stream: Readable): Promise<void> {
|
|
47
|
+
const path = this.blob(rel)
|
|
48
|
+
const dir = dirname(path)
|
|
35
49
|
|
|
36
|
-
await fs.mkdir(
|
|
50
|
+
await fs.mkdir(dir, { recursive: true })
|
|
37
51
|
await fs.writeFile(path, stream)
|
|
38
52
|
}
|
|
39
53
|
|
|
54
|
+
public async commit (rel: string, metadata: Metadata): Promise<void> {
|
|
55
|
+
const path = this.meta(rel)
|
|
56
|
+
|
|
57
|
+
await fs.writeFile(path, JSON.stringify(metadata), 'utf8')
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
public async delete (path: string): Promise<void> {
|
|
41
|
-
await
|
|
61
|
+
await Promise.all([
|
|
62
|
+
fs.rm(this.blob(path), { force: true }),
|
|
63
|
+
fs.rm(this.meta(path), { force: true })
|
|
64
|
+
])
|
|
42
65
|
}
|
|
43
66
|
|
|
44
|
-
public async move (from: string, to: string): Promise<void
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
public async move (from: string, to: string): Promise<Maybe<void>> {
|
|
68
|
+
const bf = this.blob(from)
|
|
69
|
+
const bt = this.blob(to)
|
|
70
|
+
const mf = this.meta(from)
|
|
71
|
+
const mt = this.meta(to)
|
|
47
72
|
|
|
48
|
-
await fs.mkdir(dirname(
|
|
49
|
-
|
|
73
|
+
await fs.mkdir(dirname(bt), { recursive: true })
|
|
74
|
+
|
|
75
|
+
return await this.try(async () => {
|
|
76
|
+
await Promise.all([
|
|
77
|
+
fs.rename(bf, bt),
|
|
78
|
+
fs.rename(mf, mt)
|
|
79
|
+
])
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private blob (rel: string): string {
|
|
84
|
+
return this.join(rel, '.blob')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private meta (rel: string): string {
|
|
88
|
+
return this.join(rel, '.meta')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private join (rel: string, ext: string): string {
|
|
92
|
+
return join(this.root, rel) + ext
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private async try<T = void>(action: () => Promise<T>): Promise<Maybe<T>> {
|
|
96
|
+
try {
|
|
97
|
+
return await action()
|
|
98
|
+
} catch (err: NodeJS.ErrnoException | any) {
|
|
99
|
+
if (err?.code === 'ENOENT')
|
|
100
|
+
return ERR_NOT_FOUND
|
|
101
|
+
else
|
|
102
|
+
throw err
|
|
103
|
+
}
|
|
50
104
|
}
|
|
51
105
|
}
|
package/source/providers/S3.ts
CHANGED
|
@@ -3,22 +3,15 @@ import { Blob } from 'node:buffer'
|
|
|
3
3
|
import { join } from 'node:path/posix'
|
|
4
4
|
import assert from 'node:assert'
|
|
5
5
|
import { Upload } from '@aws-sdk/lib-storage'
|
|
6
|
-
import
|
|
7
|
-
S3Client,
|
|
8
|
-
GetObjectCommand,
|
|
9
|
-
CopyObjectCommand,
|
|
10
|
-
DeleteObjectsCommand,
|
|
11
|
-
paginateListObjectsV2,
|
|
12
|
-
HeadObjectCommand,
|
|
13
|
-
NotFound,
|
|
14
|
-
DeleteObjectCommand,
|
|
15
|
-
NoSuchKey,
|
|
16
|
-
type S3ClientConfigType,
|
|
17
|
-
type ObjectIdentifier
|
|
18
|
-
} from '@aws-sdk/client-s3'
|
|
6
|
+
import * as s3 from '@aws-sdk/client-s3'
|
|
19
7
|
import * as nodeNativeFetch from 'smithy-node-native-fetch'
|
|
20
|
-
import {
|
|
8
|
+
import { console } from 'openspan'
|
|
9
|
+
import { Provider } from '../Provider'
|
|
10
|
+
import { ERR_NOT_FOUND } from '../errors'
|
|
21
11
|
import type { ReadableStream } from 'node:stream/web'
|
|
12
|
+
import type { Maybe } from '@toa.io/types'
|
|
13
|
+
import type { Metadata, Stream } from '../Entry'
|
|
14
|
+
import type { Secret, Secrets } from '../Secrets'
|
|
22
15
|
|
|
23
16
|
export interface S3Options {
|
|
24
17
|
bucket: string
|
|
@@ -27,23 +20,23 @@ export interface S3Options {
|
|
|
27
20
|
endpoint?: string
|
|
28
21
|
}
|
|
29
22
|
|
|
30
|
-
type S3Secrets =
|
|
23
|
+
type S3Secrets = Secrets<'ACCESS_KEY_ID' | 'SECRET_ACCESS_KEY'>
|
|
31
24
|
|
|
32
25
|
export class S3 extends Provider<S3Options> {
|
|
33
|
-
public static override readonly SECRETS: readonly
|
|
26
|
+
public static override readonly SECRETS: readonly Secret[] = [
|
|
34
27
|
{ name: 'ACCESS_KEY_ID', optional: true },
|
|
35
28
|
{ name: 'SECRET_ACCESS_KEY', optional: true }
|
|
36
29
|
]
|
|
37
30
|
|
|
38
|
-
|
|
39
|
-
|
|
31
|
+
private readonly bucket: string
|
|
32
|
+
private readonly client: s3.S3Client
|
|
40
33
|
|
|
41
34
|
public constructor (options: S3Options, secrets?: S3Secrets) {
|
|
42
|
-
super(options)
|
|
35
|
+
super(options, secrets)
|
|
43
36
|
|
|
44
37
|
this.bucket = options.bucket
|
|
45
38
|
|
|
46
|
-
const s3Config: S3ClientConfigType = {
|
|
39
|
+
const s3Config: s3.S3ClientConfigType = {
|
|
47
40
|
retryMode: 'adaptive',
|
|
48
41
|
...nodeNativeFetch
|
|
49
42
|
}
|
|
@@ -53,7 +46,8 @@ export class S3 extends Provider<S3Options> {
|
|
|
53
46
|
s3Config.endpoint = options.endpoint
|
|
54
47
|
}
|
|
55
48
|
|
|
56
|
-
if (options.region !== undefined)
|
|
49
|
+
if (options.region !== undefined)
|
|
50
|
+
s3Config.region = options.region
|
|
57
51
|
|
|
58
52
|
if (typeof secrets?.ACCESS_KEY_ID === 'string') {
|
|
59
53
|
assert.ok(secrets.SECRET_ACCESS_KEY !== undefined,
|
|
@@ -65,15 +59,15 @@ export class S3 extends Provider<S3Options> {
|
|
|
65
59
|
}
|
|
66
60
|
}
|
|
67
61
|
|
|
68
|
-
this.client = new S3Client(s3Config)
|
|
62
|
+
this.client = new s3.S3Client(s3Config)
|
|
69
63
|
|
|
70
64
|
this.client.middlewareStack.add((next, _context) => async (args) => {
|
|
71
|
-
if ('Key' in args.input && typeof args.input.Key === 'string')
|
|
72
65
|
// removes leading slash
|
|
66
|
+
if ('Key' in args.input && typeof args.input.Key === 'string')
|
|
73
67
|
args.input.Key = args.input.Key.replace(/^\//, '')
|
|
74
68
|
|
|
75
|
-
if ('Prefix' in args.input && typeof args.input.Prefix === 'string')
|
|
76
69
|
// removes leading slash and ensures finishing slash
|
|
70
|
+
if ('Prefix' in args.input && typeof args.input.Prefix === 'string')
|
|
77
71
|
args.input.Prefix = args.input.Prefix.replace(/^\/|\/$/g, '') + '/'
|
|
78
72
|
|
|
79
73
|
return next(args)
|
|
@@ -85,82 +79,89 @@ export class S3 extends Provider<S3Options> {
|
|
|
85
79
|
})
|
|
86
80
|
}
|
|
87
81
|
|
|
88
|
-
public async get (Key: string): Promise<
|
|
89
|
-
try {
|
|
90
|
-
const
|
|
82
|
+
public async get (Key: string): Promise<Maybe<Stream>> {
|
|
83
|
+
return await this.try<Stream>(async () => {
|
|
84
|
+
const entry = await this.client.send(new s3.GetObjectCommand({
|
|
91
85
|
Bucket: this.bucket,
|
|
92
86
|
Key
|
|
93
87
|
}))
|
|
94
88
|
|
|
95
|
-
|
|
89
|
+
const stream = entry.Body instanceof Readable
|
|
90
|
+
? entry.Body
|
|
91
|
+
: Readable.fromWeb((entry.Body instanceof Blob
|
|
92
|
+
? entry.Body.stream()
|
|
93
|
+
: entry.Body) as ReadableStream)
|
|
96
94
|
|
|
97
|
-
if (
|
|
95
|
+
if (entry.Metadata?.value === undefined)
|
|
96
|
+
return ERR_NOT_FOUND
|
|
98
97
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
98
|
+
const metadata = JSON.parse(entry.Metadata.value)
|
|
99
|
+
|
|
100
|
+
return { stream, ...metadata }
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public async head (Key: string): Promise<Maybe<Metadata>> {
|
|
105
|
+
return await this.try<Metadata>(async () => {
|
|
106
|
+
const entry = await this.client.send(new s3.HeadObjectCommand({
|
|
107
|
+
Bucket: this.bucket,
|
|
108
|
+
Key
|
|
109
|
+
}))
|
|
110
|
+
|
|
111
|
+
if (entry.Metadata?.value === undefined)
|
|
112
|
+
return ERR_NOT_FOUND
|
|
113
|
+
|
|
114
|
+
return JSON.parse(entry.Metadata.value)
|
|
115
|
+
})
|
|
106
116
|
}
|
|
107
117
|
|
|
108
|
-
public async put (
|
|
118
|
+
public async put (Key: string, stream: Readable): Promise<void> {
|
|
109
119
|
await new Upload({
|
|
110
120
|
client: this.client,
|
|
111
121
|
params: {
|
|
112
122
|
Bucket: this.bucket,
|
|
113
|
-
Key
|
|
123
|
+
Key,
|
|
114
124
|
Body: stream
|
|
115
125
|
}
|
|
116
126
|
}).done()
|
|
117
127
|
}
|
|
118
128
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// checking if given key is a single file
|
|
128
|
-
if (!Key.endsWith('/'))
|
|
129
|
-
try {
|
|
130
|
-
// DeleteObject on S3 returns no error if object does not exist
|
|
131
|
-
await client.send(new HeadObjectCommand({ Bucket, Key }))
|
|
132
|
-
await client.send(new DeleteObjectCommand({ Bucket, Key }))
|
|
133
|
-
|
|
134
|
-
return
|
|
135
|
-
} catch (err) {
|
|
136
|
-
assert.ok(err instanceof NotFound || err instanceof NoSuchKey, err as Error)
|
|
137
|
-
}
|
|
129
|
+
public async commit (Key: string, metadata: object): Promise<void> {
|
|
130
|
+
await this.client.send(new s3.CopyObjectCommand({
|
|
131
|
+
Bucket: this.bucket,
|
|
132
|
+
Key,
|
|
133
|
+
CopySource: join(this.bucket, Key),
|
|
134
|
+
Metadata: { value: JSON.stringify(metadata) },
|
|
135
|
+
MetadataDirective: 'REPLACE'
|
|
136
|
+
}))
|
|
138
137
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
for await (const page of paginateListObjectsV2({ client }, { Bucket, Prefix: Key }))
|
|
142
|
-
for (const { Key } of page.Contents ?? []) objectsToRemove.push({ Key })
|
|
143
|
-
|
|
144
|
-
// Removing all objects in parallel in batches
|
|
145
|
-
await Promise.all((function * () {
|
|
146
|
-
while (objectsToRemove.length > 0)
|
|
147
|
-
yield client.send(new DeleteObjectsCommand({
|
|
148
|
-
Bucket,
|
|
149
|
-
Delete: {
|
|
150
|
-
Objects: objectsToRemove.splice(0,
|
|
151
|
-
1000 /* max batch size for DeleteObjects */)
|
|
152
|
-
}
|
|
153
|
-
}))
|
|
154
|
-
})())
|
|
138
|
+
console.debug('Uploaded to S3', { bucket: this.bucket, path: Key, metadata })
|
|
155
139
|
}
|
|
156
140
|
|
|
157
|
-
public async
|
|
158
|
-
await this.client.send(new
|
|
159
|
-
|
|
160
|
-
Key: keyTo,
|
|
161
|
-
CopySource: join(this.bucket, from)
|
|
162
|
-
}))
|
|
141
|
+
public async delete (Key: string): Promise<void> {
|
|
142
|
+
await this.client.send(new s3.DeleteObjectCommand({ Bucket: this.bucket, Key }))
|
|
143
|
+
}
|
|
163
144
|
|
|
164
|
-
|
|
145
|
+
public async move (from: string, keyTo: string): Promise<Maybe<void>> {
|
|
146
|
+
return await this.try(async () => {
|
|
147
|
+
await this.client.send(new s3.CopyObjectCommand({
|
|
148
|
+
Bucket: this.bucket,
|
|
149
|
+
Key: keyTo,
|
|
150
|
+
CopySource: join(this.bucket, from)
|
|
151
|
+
}))
|
|
152
|
+
|
|
153
|
+
await this.client.send(new s3.DeleteObjectCommand({ Bucket: this.bucket, Key: from }))
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async try<T = void>(action: () => Promise<T>): Promise<Maybe<T>> {
|
|
158
|
+
try {
|
|
159
|
+
return await action()
|
|
160
|
+
} catch (err: any) {
|
|
161
|
+
if (err?.name === 'NotFound' || err?.name === 'NoSuchKey')
|
|
162
|
+
return ERR_NOT_FOUND
|
|
163
|
+
else
|
|
164
|
+
throw err
|
|
165
|
+
}
|
|
165
166
|
}
|
|
166
167
|
}
|