@toa.io/extensions.storages 1.0.0-alpha.2 → 1.0.0-alpha.200
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 +24 -21
- package/source/Scanner.ts +57 -24
- 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.svg +4 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toa.io/extensions.storages",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.200",
|
|
4
4
|
"description": "Toa Storages",
|
|
5
5
|
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/toa-io/toa#readme",
|
|
@@ -24,20 +24,20 @@
|
|
|
24
24
|
"test": "jest",
|
|
25
25
|
"transpile": "npx tsc"
|
|
26
26
|
},
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"dotenv": "16.3.1"
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"dotenv": "*"
|
|
30
29
|
},
|
|
31
30
|
"dependencies": {
|
|
32
|
-
"@aws-sdk/client-s3": "3.
|
|
33
|
-
"@aws-sdk/lib-storage": "3.
|
|
34
|
-
"@
|
|
35
|
-
"@toa.io/generic": "1.0.0-alpha.
|
|
36
|
-
"@toa.io/schemas": "1.0.0-alpha.
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
31
|
+
"@aws-sdk/client-s3": "3.989.0",
|
|
32
|
+
"@aws-sdk/lib-storage": "3.989.0",
|
|
33
|
+
"@aws-sdk/protocol-http": "3.374.0",
|
|
34
|
+
"@toa.io/generic": "1.0.0-alpha.173",
|
|
35
|
+
"@toa.io/schemas": "1.0.0-alpha.200",
|
|
36
|
+
"cloudinary": "2.9.0",
|
|
37
|
+
"error-value": "0.4.1",
|
|
38
|
+
"negotiator": "0.6.3",
|
|
39
|
+
"openspan": "1.0.0-alpha.173",
|
|
40
|
+
"smithy-node-native-fetch": "1.2.1"
|
|
41
41
|
},
|
|
42
|
-
"gitHead": "
|
|
42
|
+
"gitHead": "35b4adeedae2e9450ef44a74f42a53eff20ee203"
|
|
43
43
|
}
|
package/readme.md
CHANGED
|
@@ -6,15 +6,11 @@ Shared BLOB storage.
|
|
|
6
6
|
|
|
7
7
|
BLOBs are stored with the meta-information object (Entry) having the following properties:
|
|
8
8
|
|
|
9
|
-
- `
|
|
10
|
-
- `
|
|
11
|
-
- `
|
|
12
|
-
- `created
|
|
13
|
-
- `
|
|
14
|
-
- `name` - unique name
|
|
15
|
-
- `size` - size in bytes
|
|
16
|
-
- `type` - variant MIME type
|
|
17
|
-
- `meta` - object with application-specific information, empty by default
|
|
9
|
+
- `size`: size in bytes
|
|
10
|
+
- `type`: MIME type
|
|
11
|
+
- `checksum`: content checksum
|
|
12
|
+
- `created`: creation timestamp (ISO 8601)
|
|
13
|
+
- `attributes`: `Record<string, string>` with application-specific information, empty by default
|
|
18
14
|
|
|
19
15
|
### Example
|
|
20
16
|
|
|
@@ -22,12 +18,7 @@ BLOBs are stored with the meta-information object (Entry) having the following p
|
|
|
22
18
|
id: eecd837c
|
|
23
19
|
type: image/jpeg
|
|
24
20
|
created: 1698004822358
|
|
25
|
-
|
|
26
|
-
- name: thumbnail.jpeg
|
|
27
|
-
type: image/jpeg
|
|
28
|
-
- name: thumbnail.webp
|
|
29
|
-
type: image/webp
|
|
30
|
-
meta:
|
|
21
|
+
attributes:
|
|
31
22
|
face: true
|
|
32
23
|
nudity: false
|
|
33
24
|
```
|
|
@@ -39,7 +30,7 @@ containing named Storage instances, according to the annotation.
|
|
|
39
30
|
|
|
40
31
|
```javascript
|
|
41
32
|
async function effect (_, context) {
|
|
42
|
-
await context.storages.photos.
|
|
33
|
+
await context.storages.photos.get('/path/to/b4f577e0.thumbnail.jpeg')
|
|
43
34
|
}
|
|
44
35
|
```
|
|
45
36
|
|
|
@@ -49,11 +40,11 @@ async function effect (_, context) {
|
|
|
49
40
|
|
|
50
41
|
#### `async put(path: string, stream: Readable, options?: Options): Maybe<Entry>`
|
|
51
42
|
|
|
52
|
-
```
|
|
43
|
+
```ts
|
|
53
44
|
interface Options {
|
|
54
45
|
claim?: string
|
|
55
46
|
accept?: string
|
|
56
|
-
|
|
47
|
+
attributes?: Record<string, string>
|
|
57
48
|
}
|
|
58
49
|
```
|
|
59
50
|
|
|
@@ -75,15 +66,13 @@ are: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/heic`, `image/
|
|
|
75
66
|
|
|
76
67
|
See [source](source/Scanner.ts).
|
|
77
68
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
#### `async get(path: string): Maybe<Entry>`
|
|
69
|
+
#### `async head(path: string): Maybe<Entry>`
|
|
81
70
|
|
|
82
71
|
Get an entry.
|
|
83
72
|
|
|
84
73
|
If the entry does not exist, a `NOT_FOUND` error is returned.
|
|
85
74
|
|
|
86
|
-
#### `async
|
|
75
|
+
#### `async get(path: string): Maybe<Readable>`
|
|
87
76
|
|
|
88
77
|
Fetch the BLOB specified by `path`. If the path does not exist, a `NOT_FOUND` error is returned.
|
|
89
78
|
|
|
@@ -97,32 +86,20 @@ Fetch the BLOB specified by `path`. If the path does not exist, a `NOT_FOUND` er
|
|
|
97
86
|
|
|
98
87
|
Delete the entry specified by `path`.
|
|
99
88
|
|
|
100
|
-
#### `async
|
|
101
|
-
|
|
102
|
-
Get ordered list of `id`s of entries in under the `path`.
|
|
103
|
-
|
|
104
|
-
#### `async permute(path: string, ids: string[]): Maybe<void>`
|
|
105
|
-
|
|
106
|
-
Reorder entries under the `path`.
|
|
107
|
-
|
|
108
|
-
Given list must be a permutation of the current list, otherwise a `PERMUTATION_MISMATCH` error is
|
|
109
|
-
returned.
|
|
110
|
-
|
|
111
|
-
#### `async diversify(path: string, name: string, stream: Readable): Maybe<void>`
|
|
112
|
-
|
|
113
|
-
Add or replace a `name` variant of the entry specified by `path`.
|
|
114
|
-
|
|
115
|
-
#### `async conceal(path: string): Maybe<void>`
|
|
116
|
-
|
|
117
|
-
Remove the entry from the list.
|
|
89
|
+
#### `async move(path: string, to: string): Maybe<void>`
|
|
118
90
|
|
|
119
|
-
|
|
91
|
+
Moves the entry specified by `path` to the new path.
|
|
120
92
|
|
|
121
|
-
|
|
93
|
+
`to` may be an absolute or relative path (starting with `.`), if it ends with `/`, the entry is
|
|
94
|
+
moved to the `to` directory, otherwise, the entry is moved to the `to` path.
|
|
122
95
|
|
|
123
|
-
|
|
96
|
+
The following examples are equivalent:
|
|
124
97
|
|
|
125
|
-
|
|
98
|
+
```javascript
|
|
99
|
+
await storage.move('/path/to/eecd837c', '/path/to/sub/eecd837c')
|
|
100
|
+
await storage.move('/path/to/eecd837c', './sub/eecd837c')
|
|
101
|
+
await storage.move('/path/to/eecd837c', './sub/')
|
|
102
|
+
```
|
|
126
103
|
|
|
127
104
|
## Providers
|
|
128
105
|
|
|
@@ -132,7 +109,7 @@ Custom providers are not supported.
|
|
|
132
109
|
|
|
133
110
|
### Amazon S3
|
|
134
111
|
|
|
135
|
-
Annotation formats
|
|
112
|
+
Annotation formats are like:
|
|
136
113
|
|
|
137
114
|
```yaml
|
|
138
115
|
storages:
|
|
@@ -154,15 +131,83 @@ and [`toa env`](/runtime/cli/readme.md#env)
|
|
|
154
131
|
for local environment.
|
|
155
132
|
`endpoint` parameter is optional.
|
|
156
133
|
|
|
134
|
+
### Cloudinary
|
|
135
|
+
|
|
136
|
+
[Cloudinary](https://cloudinary.com) provider is used to store and transform media files.
|
|
137
|
+
|
|
138
|
+
Objects stored in the Cloudinary storage can be requested with transformations,
|
|
139
|
+
using dot-separated path extensions: `/path/to/eecd837c.{extension}.{extension}`.
|
|
140
|
+
|
|
141
|
+
Transformation is defined by the `extension` property, which is a regular expression with named
|
|
142
|
+
groups.
|
|
143
|
+
The named groups are used to replace the placeholders in the `transform` object.
|
|
144
|
+
|
|
145
|
+
`transform` object is an element of
|
|
146
|
+
the [Cloudinary `transformation` array](https://cloudinary.com/documentation/node_image_manipulation#apply_common_image_transformations).
|
|
147
|
+
|
|
148
|
+
> - `zoom` should be specified as integer [0–100].
|
|
149
|
+
> - `format` value `jpeg` is converted to `jpg`.
|
|
150
|
+
> - `^` and `$` are added to the regular expression automatically, unless they are already present.
|
|
151
|
+
|
|
152
|
+
Annotation format is:
|
|
153
|
+
|
|
154
|
+
```yaml
|
|
155
|
+
storages:
|
|
156
|
+
media:
|
|
157
|
+
provider: cloudinary
|
|
158
|
+
environment: my-cloud
|
|
159
|
+
type: image # image or video
|
|
160
|
+
prefix: my-app
|
|
161
|
+
transformations:
|
|
162
|
+
- extension: (?<width>\d*)x(?<height>\d*)(z(?<zoom>\d*))?
|
|
163
|
+
transformation:
|
|
164
|
+
width: <width>
|
|
165
|
+
height: <height>
|
|
166
|
+
zoom: <zoom>
|
|
167
|
+
crop: thumb
|
|
168
|
+
gravity: face
|
|
169
|
+
optional: true
|
|
170
|
+
- extension: (?<format>jpeg|webp)
|
|
171
|
+
transformation:
|
|
172
|
+
fetch_format: <format>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Path extensions must be specified in the same order as in the annotation.
|
|
176
|
+
|
|
177
|
+
For the example above:
|
|
178
|
+
|
|
179
|
+
- `/path/to/eecd837c.100x100.jpeg`: matches
|
|
180
|
+
- `/path/to/eecd837c.jpeg.100x100`: does not match
|
|
181
|
+
|
|
182
|
+
If a path extension is not matched,
|
|
183
|
+
or if at least one of the transformations is not matched (unless it is `optional`),
|
|
184
|
+
then an error is returned.
|
|
185
|
+
|
|
186
|
+
Secrets:
|
|
187
|
+
|
|
188
|
+
- `API_KEY`
|
|
189
|
+
- `API_SECRET`
|
|
190
|
+
|
|
157
191
|
### Filesystem
|
|
158
192
|
|
|
159
193
|
Annotation format is:
|
|
160
194
|
|
|
161
195
|
```yaml
|
|
162
196
|
storages:
|
|
163
|
-
photos
|
|
197
|
+
photos:
|
|
164
198
|
provider: fs
|
|
165
|
-
path: /var/my-
|
|
199
|
+
path: /var/my-photos
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
[Kubernetes PVC](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) can be mounted to
|
|
203
|
+
the storage:
|
|
204
|
+
|
|
205
|
+
```yaml
|
|
206
|
+
storages:
|
|
207
|
+
photos:
|
|
208
|
+
provider: fs
|
|
209
|
+
path: /var/my-photos
|
|
210
|
+
claim: photos-pvc
|
|
166
211
|
```
|
|
167
212
|
|
|
168
213
|
### Temporary
|
|
@@ -198,17 +243,17 @@ Variants, on the other hand, are not deduplicated across different entries.
|
|
|
198
243
|
|
|
199
244
|
Underlying directory structure:
|
|
200
245
|
|
|
201
|
-
```
|
|
246
|
+
```text
|
|
202
247
|
/temp
|
|
203
248
|
c28f4dfd # random id
|
|
204
249
|
/blobs
|
|
205
250
|
b4f577e0 # checksum
|
|
206
251
|
/storage
|
|
207
252
|
/path/to
|
|
208
|
-
|
|
253
|
+
/.entries/
|
|
254
|
+
b4f577e0 # entry
|
|
209
255
|
/b4f577e0
|
|
210
|
-
.
|
|
211
|
-
thumbnail.jpeg # variant BLOBs
|
|
256
|
+
thumbnail.jpeg # variant
|
|
212
257
|
thumbnail.webp
|
|
213
258
|
```
|
|
214
259
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
type: object
|
|
2
|
+
properties:
|
|
3
|
+
provider:
|
|
4
|
+
const: cloudinary
|
|
5
|
+
environment:
|
|
6
|
+
type: string
|
|
7
|
+
type:
|
|
8
|
+
enum:
|
|
9
|
+
- image
|
|
10
|
+
- video
|
|
11
|
+
prefix:
|
|
12
|
+
type: string
|
|
13
|
+
eager:
|
|
14
|
+
type: array
|
|
15
|
+
items: &transformation
|
|
16
|
+
type: object
|
|
17
|
+
transformations:
|
|
18
|
+
type: array
|
|
19
|
+
items:
|
|
20
|
+
type: object
|
|
21
|
+
properties:
|
|
22
|
+
extension:
|
|
23
|
+
type: string
|
|
24
|
+
transformation:
|
|
25
|
+
anyOf:
|
|
26
|
+
- *transformation
|
|
27
|
+
- type: array
|
|
28
|
+
items: *transformation
|
|
29
|
+
optional:
|
|
30
|
+
type: boolean
|
|
31
|
+
required:
|
|
32
|
+
- extension
|
|
33
|
+
- transformation
|
|
34
|
+
required:
|
|
35
|
+
- environment
|
|
36
|
+
- type
|
|
37
|
+
additionalProperties: false
|
package/schemas/fs.cos.yaml
CHANGED
package/schemas/s3.cos.yaml
CHANGED
package/schemas/tmp.cos.yaml
CHANGED
package/source/Entry.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
id: string
|
|
3
|
-
size: number
|
|
4
|
-
type: string
|
|
5
|
-
created: number
|
|
6
|
-
variants: Variant[]
|
|
7
|
-
meta: Record<string, unknown>
|
|
8
|
-
}
|
|
1
|
+
import type { Readable } from 'node:stream'
|
|
9
2
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
3
|
+
export type Entry = { id: string } & Metadata
|
|
4
|
+
export type Stream = { stream: Readable } & Metadata
|
|
5
|
+
|
|
6
|
+
export interface Metadata {
|
|
13
7
|
type: string
|
|
8
|
+
size: number | null
|
|
9
|
+
checksum: string
|
|
10
|
+
created: string
|
|
11
|
+
attributes: Attributes
|
|
12
|
+
range?: string
|
|
13
|
+
partial?: boolean
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
export type Attributes = Record<string, string>
|
package/source/Factory.ts
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import assert from 'node:assert'
|
|
2
|
+
import { console } from 'openspan'
|
|
2
3
|
import { decode } from '@toa.io/generic'
|
|
3
4
|
import { providers } from './providers'
|
|
4
5
|
import { Storage, type Storages } from './Storage'
|
|
5
6
|
import { Aspect } from './Aspect'
|
|
6
|
-
import {
|
|
7
|
+
import { ENV_PREFIX } from './deployment'
|
|
7
8
|
import { validateAnnotation } from './Annotation'
|
|
9
|
+
import type { Constructor } from './Provider'
|
|
8
10
|
import type { Declaration } from './providers'
|
|
9
11
|
import type { Annotation } from './Annotation'
|
|
10
|
-
import type {
|
|
12
|
+
import type { Secrets } from './Secrets'
|
|
11
13
|
|
|
12
14
|
export class Factory {
|
|
13
15
|
private readonly annotation: Annotation
|
|
14
16
|
|
|
15
17
|
public constructor () {
|
|
16
|
-
const env = process.env
|
|
18
|
+
const env = process.env[ENV_PREFIX]
|
|
17
19
|
|
|
18
|
-
assert.ok(env !== undefined,
|
|
20
|
+
assert.ok(env !== undefined, `${ENV_PREFIX} is not defined`)
|
|
19
21
|
|
|
20
22
|
this.annotation = decode(env)
|
|
21
23
|
|
|
@@ -38,23 +40,28 @@ export class Factory {
|
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
private createStorage (name: string, declaration: Declaration): Storage {
|
|
41
|
-
const { provider:
|
|
42
|
-
const Provider:
|
|
43
|
+
const { provider: id, ...options } = declaration
|
|
44
|
+
const Provider: Constructor = providers[id]
|
|
43
45
|
const secrets = this.resolveSecrets(name, Provider)
|
|
44
46
|
const provider = new Provider(options, secrets)
|
|
45
47
|
|
|
48
|
+
console.debug('Storage created', {
|
|
49
|
+
name,
|
|
50
|
+
provider: id,
|
|
51
|
+
...(provider.root === undefined ? undefined : { root: provider.root })
|
|
52
|
+
})
|
|
53
|
+
|
|
46
54
|
return new Storage(provider)
|
|
47
55
|
}
|
|
48
56
|
|
|
49
|
-
private resolveSecrets (storageName: string,
|
|
50
|
-
Class: ProviderConstructor): ProviderSecrets {
|
|
57
|
+
private resolveSecrets (storageName: string, Class: Constructor): Secrets {
|
|
51
58
|
if (Class.SECRETS === undefined)
|
|
52
59
|
return {}
|
|
53
60
|
|
|
54
61
|
const secrets: Record<string, string | undefined> = {}
|
|
55
62
|
|
|
56
63
|
for (const secret of Class.SECRETS) {
|
|
57
|
-
const variable = `${
|
|
64
|
+
const variable = `${ENV_PREFIX}_${storageName}_${secret.name}`.toUpperCase()
|
|
58
65
|
const value = process.env[variable]
|
|
59
66
|
|
|
60
67
|
assert.ok(secret.optional === true || value !== undefined,
|
package/source/Provider.ts
CHANGED
|
@@ -1,33 +1,36 @@
|
|
|
1
|
-
import { type Readable } from 'node:stream'
|
|
2
1
|
import * as assert from 'node:assert'
|
|
2
|
+
import type { Metadata, Stream } from './Entry'
|
|
3
|
+
import type { Readable } from 'node:stream'
|
|
4
|
+
import type { Maybe } from '@toa.io/types'
|
|
5
|
+
import type { Secret, Secrets } from './Secrets'
|
|
6
|
+
|
|
7
|
+
export abstract class Provider<Options = unknown> {
|
|
8
|
+
public static readonly SECRETS?: readonly Secret[]
|
|
9
|
+
public readonly root?: string
|
|
10
|
+
public readonly options: Options
|
|
11
|
+
|
|
12
|
+
protected constructor (options: Options, secrets?: Secrets) {
|
|
13
|
+
this.options = options
|
|
14
|
+
|
|
15
|
+
new.target.SECRETS?.forEach(({ name, optional }) =>
|
|
16
|
+
assert.ok(optional === true || secrets?.[name] !== undefined, `Missing secret '${name}'`))
|
|
17
|
+
}
|
|
3
18
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export interface ProviderSecret {
|
|
7
|
-
readonly name: string
|
|
8
|
-
readonly optional?: boolean
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export abstract class Provider<Options = void> {
|
|
12
|
-
public static readonly SECRETS: readonly ProviderSecret[] = []
|
|
19
|
+
public abstract get (path: string, options?: unknown): Promise<Maybe<Stream>>
|
|
13
20
|
|
|
14
|
-
public
|
|
15
|
-
for (const { name, optional = false } of new.target.SECRETS)
|
|
16
|
-
assert.ok(optional || secrets?.[name] !== undefined, `Missing secret '${name}'`)
|
|
17
|
-
}
|
|
21
|
+
public abstract head (path: string): Promise<Maybe<Metadata>>
|
|
18
22
|
|
|
19
|
-
public abstract
|
|
23
|
+
public abstract put (path: string, stream: Readable): Promise<void>
|
|
20
24
|
|
|
21
|
-
public abstract
|
|
25
|
+
public abstract commit (path: string, metadata: Metadata): Promise<void>
|
|
22
26
|
|
|
23
27
|
public abstract delete (path: string): Promise<void>
|
|
24
28
|
|
|
25
|
-
public abstract move (from: string, to: string): Promise<void
|
|
29
|
+
public abstract move (from: string, to: string): Promise<Maybe<void>>
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
export interface
|
|
29
|
-
|
|
30
|
-
prototype: Provider
|
|
32
|
+
export interface Constructor<Options = any> {
|
|
33
|
+
SECRETS?: readonly Secret[]
|
|
31
34
|
|
|
32
|
-
new (options:
|
|
35
|
+
new (options: Options, secrets?: Secrets): Provider<Options>
|
|
33
36
|
}
|
package/source/Scanner.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { PassThrough, type TransformCallback } from 'node:stream'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
|
-
import { negotiate } from '@toa.io/agent'
|
|
4
3
|
import { Err } from 'error-value'
|
|
4
|
+
import Negotiator from 'negotiator'
|
|
5
5
|
|
|
6
6
|
export class Scanner extends PassThrough {
|
|
7
7
|
public size = 0
|
|
8
8
|
public type = 'application/octet-stream'
|
|
9
|
-
public error
|
|
9
|
+
public error?: Error
|
|
10
10
|
|
|
11
11
|
private readonly hash = createHash('md5')
|
|
12
12
|
private readonly claim?: string
|
|
13
13
|
private readonly accept?: string
|
|
14
|
+
private readonly limit?: number
|
|
14
15
|
private position = 0
|
|
15
|
-
private
|
|
16
|
+
private completed = false
|
|
16
17
|
private readonly chunks: Buffer[] = []
|
|
17
18
|
|
|
18
19
|
public constructor (control?: ScanOptions) {
|
|
@@ -20,6 +21,7 @@ export class Scanner extends PassThrough {
|
|
|
20
21
|
|
|
21
22
|
this.claim = control?.claim
|
|
22
23
|
this.accept = control?.accept
|
|
24
|
+
this.limit = control?.limit
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
public digest (): string {
|
|
@@ -37,7 +39,7 @@ export class Scanner extends PassThrough {
|
|
|
37
39
|
this.size += buffer.length
|
|
38
40
|
this.hash.update(buffer)
|
|
39
41
|
|
|
40
|
-
if (this.
|
|
42
|
+
if (this.completed)
|
|
41
43
|
return
|
|
42
44
|
|
|
43
45
|
if (this.position + buffer.length > HEADER_SIZE)
|
|
@@ -48,13 +50,22 @@ export class Scanner extends PassThrough {
|
|
|
48
50
|
|
|
49
51
|
if (this.position === HEADER_SIZE)
|
|
50
52
|
this.complete()
|
|
53
|
+
|
|
54
|
+
if (this.limit !== undefined && this.size > this.limit)
|
|
55
|
+
this.interrupt(ERR_LIMIT_EXCEEDED)
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
private complete (): void {
|
|
54
59
|
const header = Buffer.concat(this.chunks).toString('hex')
|
|
55
60
|
|
|
56
61
|
const signature = SIGNATURES
|
|
57
|
-
.find(({ hex,
|
|
62
|
+
.find(({ off, hex, expression }) => {
|
|
63
|
+
const sig = header.slice(off, off + hex.length)
|
|
64
|
+
|
|
65
|
+
return expression === undefined
|
|
66
|
+
? sig === hex
|
|
67
|
+
: expression.test(sig)
|
|
68
|
+
})
|
|
58
69
|
|
|
59
70
|
const type = signature?.type ?? this.claim
|
|
60
71
|
|
|
@@ -64,7 +75,7 @@ export class Scanner extends PassThrough {
|
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
this.verify(signature)
|
|
67
|
-
this.
|
|
78
|
+
this.completed = true
|
|
68
79
|
}
|
|
69
80
|
|
|
70
81
|
private verify (signature: Signature | undefined): void {
|
|
@@ -83,51 +94,73 @@ export class Scanner extends PassThrough {
|
|
|
83
94
|
if (this.accept === undefined)
|
|
84
95
|
return
|
|
85
96
|
|
|
86
|
-
const unacceptable = negotiate(this.accept, [type]) === null
|
|
97
|
+
const unacceptable = this.negotiate(this.accept, [type]) === null
|
|
87
98
|
|
|
88
99
|
if (unacceptable)
|
|
89
100
|
this.interrupt(ERR_NOT_ACCEPTABLE)
|
|
90
101
|
}
|
|
91
102
|
|
|
103
|
+
private negotiate (accept: string, type: string[]): string | null {
|
|
104
|
+
return new Negotiator({ headers: { accept } }).mediaType(type) ?? null
|
|
105
|
+
}
|
|
106
|
+
|
|
92
107
|
private interrupt (error: Error): void {
|
|
108
|
+
this.completed = true
|
|
93
109
|
this.error = error
|
|
94
|
-
this.
|
|
110
|
+
this.destroy(error)
|
|
95
111
|
}
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
// https://en.wikipedia.org/wiki/List_of_file_signatures
|
|
99
115
|
const SIGNATURES: Signature[] = [
|
|
100
|
-
{ hex: '
|
|
101
|
-
{ hex: '
|
|
102
|
-
{ hex: '
|
|
103
|
-
{ hex: '
|
|
104
|
-
{ hex: '
|
|
105
|
-
{ hex: '
|
|
106
|
-
{ hex: '
|
|
107
|
-
{ hex: '
|
|
108
|
-
{ hex: '
|
|
109
|
-
{ hex: '
|
|
116
|
+
{ hex: 'ff d8 ff e0', off: 0, type: 'image/jpeg' },
|
|
117
|
+
{ hex: 'ff d8 ff e1', off: 0, type: 'image/jpeg' },
|
|
118
|
+
{ hex: 'ff d8 ff e2', off: 0, type: 'image/jpeg' },
|
|
119
|
+
{ hex: 'ff d8 ff ee', off: 0, type: 'image/jpeg' },
|
|
120
|
+
{ hex: 'ff d8 ff db', off: 0, type: 'image/jpeg' },
|
|
121
|
+
{ hex: '89 50 4e 47', off: 0, type: 'image/png' },
|
|
122
|
+
{ hex: '47 49 46 38', off: 0, type: 'image/gif' },
|
|
123
|
+
{ hex: '52 49 46 46 ?? ?? ?? ?? 57 45 42 50', off: 0, type: 'image/webp' },
|
|
124
|
+
{ hex: '4a 58 4c 20 0d 0a 87 0a', off: 8, type: 'image/jxl' },
|
|
125
|
+
{ hex: '66 74 79 70 68 65 69 63', off: 8, type: 'image/heic' },
|
|
126
|
+
{ hex: '66 74 79 70 61 76 69 66', off: 8, type: 'image/avif' },
|
|
127
|
+
{ hex: '52 49 46 46 ?? ?? ?? ?? 41 56 49 20', off: 0, type: 'video/avi' },
|
|
128
|
+
{ hex: '52 49 46 46 ?? ?? ?? ?? 57 41 56 45', off: 0, type: 'audio/wav' }
|
|
110
129
|
/*
|
|
111
130
|
When adding a new signature, include a copyright-free sample file in the `.tests` directory
|
|
112
|
-
and update the
|
|
131
|
+
and update the `signatures` test group in `Storage.test.ts`.
|
|
113
132
|
*/
|
|
114
|
-
]
|
|
133
|
+
].map((signature: Signature) => {
|
|
134
|
+
signature.hex = signature.hex.replaceAll(' ', '')
|
|
135
|
+
|
|
136
|
+
if (signature.hex.includes('??')) {
|
|
137
|
+
const expression = signature.hex.replaceAll(/(?<wildcards>\?{1,24})/g,
|
|
138
|
+
(_, wildcards) => `[0-9a-f]{${wildcards.length}}`)
|
|
139
|
+
|
|
140
|
+
signature.expression = new RegExp(expression, 'i')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return signature
|
|
144
|
+
})
|
|
115
145
|
|
|
116
146
|
const HEADER_SIZE = SIGNATURES
|
|
117
147
|
.reduce((max, { off, hex }) => Math.max(max, off + hex.length), 0) / 2
|
|
118
148
|
|
|
119
149
|
const KNOWN_TYPES = new Set(SIGNATURES.map(({ type }) => type))
|
|
120
150
|
|
|
121
|
-
const ERR_TYPE_MISMATCH = Err('TYPE_MISMATCH')
|
|
122
|
-
const ERR_NOT_ACCEPTABLE = Err('NOT_ACCEPTABLE')
|
|
151
|
+
const ERR_TYPE_MISMATCH = new Err('TYPE_MISMATCH')
|
|
152
|
+
const ERR_NOT_ACCEPTABLE = new Err('NOT_ACCEPTABLE')
|
|
153
|
+
const ERR_LIMIT_EXCEEDED = new Err('LIMIT_EXCEEDED')
|
|
123
154
|
|
|
124
155
|
export interface ScanOptions {
|
|
125
156
|
claim?: string
|
|
126
157
|
accept?: string
|
|
158
|
+
limit?: number
|
|
127
159
|
}
|
|
128
160
|
|
|
129
161
|
interface Signature {
|
|
130
|
-
hex: string
|
|
131
|
-
off: number
|
|
132
162
|
type: string
|
|
163
|
+
off: number
|
|
164
|
+
hex: string
|
|
165
|
+
expression?: RegExp
|
|
133
166
|
}
|