@toa.io/extensions.storages 1.0.0-alpha.26 → 1.0.0-alpha.263
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/CHANGELOG.md +8 -0
- package/package.json +12 -11
- package/readme.md +82 -35
- 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 +17 -10
- package/source/Provider.ts +21 -22
- package/source/Scanner.ts +58 -26
- package/source/Secrets.ts +6 -0
- package/source/Storage.test.ts +117 -256
- package/source/Storage.ts +92 -196
- 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 +73 -31
- package/source/providers/S3.ts +74 -103
- package/source/providers/Temporary.ts +2 -3
- package/source/providers/Test.ts +4 -4
- package/source/providers/index.test.ts +99 -83
- 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 +12 -6
- package/transpiled/Factory.js.map +1 -1
- package/transpiled/Provider.d.ts +17 -18
- 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 +21 -19
- package/transpiled/Storage.js +73 -135
- 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 -9
- package/transpiled/providers/FileSystem.js +61 -30
- package/transpiled/providers/FileSystem.js.map +1 -1
- package/transpiled/providers/S3.d.ts +14 -17
- package/transpiled/providers/S3.js +60 -77
- 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 -55
- package/source/providers/S3.test.ts +0 -134
- package/transpiled/providers/Memory.d.ts +0 -15
- package/transpiled/providers/Memory.js +0 -72
- package/transpiled/providers/Memory.js.map +0 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Change Log
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
|
+
|
|
6
|
+
# [1.0.0-alpha.263](https://github.com/toa-io/toa/compare/v1.0.0-alpha.262...v1.0.0-alpha.263) (2026-08-29)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @toa.io/extensions.storages
|
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.263",
|
|
4
4
|
"description": "Toa Storages",
|
|
5
5
|
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/toa-io/toa#readme",
|
|
@@ -28,15 +28,16 @@
|
|
|
28
28
|
"dotenv": "*"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@aws-sdk/client-s3": "3.
|
|
32
|
-
"@aws-sdk/lib-storage": "3.
|
|
33
|
-
"@
|
|
34
|
-
"@toa.io/generic": "1.0.0-alpha.
|
|
35
|
-
"@toa.io/schemas": "1.0.0-alpha.
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
31
|
+
"@aws-sdk/client-s3": "3.1107.0",
|
|
32
|
+
"@aws-sdk/lib-storage": "3.1107.0",
|
|
33
|
+
"@smithy/protocol-http": "3.3.0",
|
|
34
|
+
"@toa.io/generic": "1.0.0-alpha.254",
|
|
35
|
+
"@toa.io/schemas": "1.0.0-alpha.263",
|
|
36
|
+
"cloudinary": "2.9.0",
|
|
37
|
+
"error-value": "0.4.1",
|
|
38
|
+
"negotiator": "0.6.3",
|
|
39
|
+
"openspan": "1.0.0-alpha.254",
|
|
40
|
+
"smithy-node-native-fetch": "1.2.1"
|
|
40
41
|
},
|
|
41
|
-
"gitHead": "
|
|
42
|
+
"gitHead": "165daa284bb7a75907a593fde359e3553b083417"
|
|
42
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,13 +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
|
-
#### `async
|
|
69
|
+
#### `async head(path: string): Maybe<Entry>`
|
|
79
70
|
|
|
80
71
|
Get an entry.
|
|
81
72
|
|
|
82
73
|
If the entry does not exist, a `NOT_FOUND` error is returned.
|
|
83
74
|
|
|
84
|
-
#### `async
|
|
75
|
+
#### `async get(path: string): Maybe<Readable>`
|
|
85
76
|
|
|
86
77
|
Fetch the BLOB specified by `path`. If the path does not exist, a `NOT_FOUND` error is returned.
|
|
87
78
|
|
|
@@ -110,18 +101,6 @@ await storage.move('/path/to/eecd837c', './sub/eecd837c')
|
|
|
110
101
|
await storage.move('/path/to/eecd837c', './sub/')
|
|
111
102
|
```
|
|
112
103
|
|
|
113
|
-
#### `async entries(path: string): Entry[]`
|
|
114
|
-
|
|
115
|
-
Get a list of entries under the `path`.
|
|
116
|
-
|
|
117
|
-
#### `async diversify(path: string, name: string, stream: Readable): Maybe<void>`
|
|
118
|
-
|
|
119
|
-
Add or replace a `name` variant of the entry specified by `path`.
|
|
120
|
-
|
|
121
|
-
#### `async annotate(path: string, key: string, value: any): Maybe<void>`
|
|
122
|
-
|
|
123
|
-
Set a `key` property in the `meta` of the entry specified by `path`.
|
|
124
|
-
|
|
125
104
|
## Providers
|
|
126
105
|
|
|
127
106
|
Storage uses underlying providers to store BLOBs and entries.
|
|
@@ -152,15 +131,83 @@ and [`toa env`](/runtime/cli/readme.md#env)
|
|
|
152
131
|
for local environment.
|
|
153
132
|
`endpoint` parameter is optional.
|
|
154
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
|
+
|
|
155
191
|
### Filesystem
|
|
156
192
|
|
|
157
193
|
Annotation format is:
|
|
158
194
|
|
|
159
195
|
```yaml
|
|
160
196
|
storages:
|
|
161
|
-
photos
|
|
197
|
+
photos:
|
|
162
198
|
provider: fs
|
|
163
|
-
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
|
|
164
211
|
```
|
|
165
212
|
|
|
166
213
|
### Temporary
|
|
@@ -196,7 +243,7 @@ Variants, on the other hand, are not deduplicated across different entries.
|
|
|
196
243
|
|
|
197
244
|
Underlying directory structure:
|
|
198
245
|
|
|
199
|
-
```
|
|
246
|
+
```text
|
|
200
247
|
/temp
|
|
201
248
|
c28f4dfd # random id
|
|
202
249
|
/blobs
|
|
@@ -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
|
|
|
46
|
-
|
|
48
|
+
console.debug('Storage created', {
|
|
49
|
+
name,
|
|
50
|
+
provider: id,
|
|
51
|
+
...(provider.root === undefined ? undefined : { root: provider.root })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return new Storage(provider, { name, provider: id })
|
|
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,37 +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'
|
|
3
6
|
|
|
4
|
-
export
|
|
7
|
+
export abstract class Provider<Options = unknown> {
|
|
8
|
+
public static readonly SECRETS?: readonly Secret[]
|
|
9
|
+
public readonly root?: string
|
|
10
|
+
public readonly options: Options
|
|
5
11
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
readonly optional?: boolean
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export abstract class Provider<Options = void> {
|
|
12
|
-
public static readonly SECRETS: readonly ProviderSecret[] = []
|
|
12
|
+
protected constructor (options: Options, secrets?: Secrets) {
|
|
13
|
+
this.options = options
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
assert.ok(optional || secrets?.[name] !== undefined, `Missing secret '${name}'`)
|
|
15
|
+
new.target.SECRETS?.forEach(({ name, optional }) =>
|
|
16
|
+
assert.ok(optional === true || secrets?.[name] !== undefined, `Missing secret '${name}'`))
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
public abstract get (path: string): Promise<
|
|
19
|
+
public abstract get (path: string, options?: unknown): Promise<Maybe<Stream>>
|
|
20
20
|
|
|
21
|
-
public abstract
|
|
21
|
+
public abstract head (path: string): Promise<Maybe<Metadata>>
|
|
22
22
|
|
|
23
|
-
public abstract put (path: string,
|
|
23
|
+
public abstract put (path: string, stream: Readable): Promise<void>
|
|
24
24
|
|
|
25
|
-
public abstract
|
|
25
|
+
public abstract commit (path: string, metadata: Metadata): Promise<void>
|
|
26
26
|
|
|
27
|
-
public abstract
|
|
27
|
+
public abstract delete (path: string): Promise<void>
|
|
28
28
|
|
|
29
|
-
public abstract
|
|
29
|
+
public abstract move (from: string, to: string): Promise<Maybe<void>>
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export interface
|
|
33
|
-
|
|
34
|
-
prototype: Provider
|
|
32
|
+
export interface Constructor<Options = any> {
|
|
33
|
+
SECRETS?: readonly Secret[]
|
|
35
34
|
|
|
36
|
-
new (options:
|
|
35
|
+
new (options: Options, secrets?: Secrets): Provider<Options>
|
|
37
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,14 +21,14 @@ 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 {
|
|
26
28
|
return this.hash.digest('hex')
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
public override _transform
|
|
30
|
-
(buffer: Buffer, encoding: BufferEncoding, callback: TransformCallback): void {
|
|
31
|
+
public override _transform (buffer: Buffer, encoding: BufferEncoding, callback: TransformCallback): void {
|
|
31
32
|
super._transform(buffer, encoding, callback)
|
|
32
33
|
|
|
33
34
|
this.process(buffer)
|
|
@@ -37,7 +38,7 @@ export class Scanner extends PassThrough {
|
|
|
37
38
|
this.size += buffer.length
|
|
38
39
|
this.hash.update(buffer)
|
|
39
40
|
|
|
40
|
-
if (this.
|
|
41
|
+
if (this.completed)
|
|
41
42
|
return
|
|
42
43
|
|
|
43
44
|
if (this.position + buffer.length > HEADER_SIZE)
|
|
@@ -48,13 +49,22 @@ export class Scanner extends PassThrough {
|
|
|
48
49
|
|
|
49
50
|
if (this.position === HEADER_SIZE)
|
|
50
51
|
this.complete()
|
|
52
|
+
|
|
53
|
+
if (this.limit !== undefined && this.size > this.limit)
|
|
54
|
+
this.interrupt(ERR_LIMIT_EXCEEDED)
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
private complete (): void {
|
|
54
58
|
const header = Buffer.concat(this.chunks).toString('hex')
|
|
55
59
|
|
|
56
60
|
const signature = SIGNATURES
|
|
57
|
-
.find(({ hex,
|
|
61
|
+
.find(({ off, hex, expression }) => {
|
|
62
|
+
const sig = header.slice(off, off + hex.length)
|
|
63
|
+
|
|
64
|
+
return expression === undefined
|
|
65
|
+
? sig === hex
|
|
66
|
+
: expression.test(sig)
|
|
67
|
+
})
|
|
58
68
|
|
|
59
69
|
const type = signature?.type ?? this.claim
|
|
60
70
|
|
|
@@ -64,7 +74,7 @@ export class Scanner extends PassThrough {
|
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
this.verify(signature)
|
|
67
|
-
this.
|
|
77
|
+
this.completed = true
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
private verify (signature: Signature | undefined): void {
|
|
@@ -83,51 +93,73 @@ export class Scanner extends PassThrough {
|
|
|
83
93
|
if (this.accept === undefined)
|
|
84
94
|
return
|
|
85
95
|
|
|
86
|
-
const unacceptable = negotiate(this.accept, [type]) === null
|
|
96
|
+
const unacceptable = this.negotiate(this.accept, [type]) === null
|
|
87
97
|
|
|
88
98
|
if (unacceptable)
|
|
89
99
|
this.interrupt(ERR_NOT_ACCEPTABLE)
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
private negotiate (accept: string, type: string[]): string | null {
|
|
103
|
+
return new Negotiator({ headers: { accept } }).mediaType(type) ?? null
|
|
104
|
+
}
|
|
105
|
+
|
|
92
106
|
private interrupt (error: Error): void {
|
|
107
|
+
this.completed = true
|
|
93
108
|
this.error = error
|
|
94
|
-
this.
|
|
109
|
+
this.destroy(error)
|
|
95
110
|
}
|
|
96
111
|
}
|
|
97
112
|
|
|
98
113
|
// https://en.wikipedia.org/wiki/List_of_file_signatures
|
|
99
114
|
const SIGNATURES: Signature[] = [
|
|
100
|
-
{ hex: '
|
|
101
|
-
{ hex: '
|
|
102
|
-
{ hex: '
|
|
103
|
-
{ hex: '
|
|
104
|
-
{ hex: '
|
|
105
|
-
{ hex: '
|
|
106
|
-
{ hex: '
|
|
107
|
-
{ hex: '
|
|
108
|
-
{ hex: '
|
|
109
|
-
{ hex: '
|
|
115
|
+
{ hex: 'ff d8 ff e0', off: 0, type: 'image/jpeg' },
|
|
116
|
+
{ hex: 'ff d8 ff e1', off: 0, type: 'image/jpeg' },
|
|
117
|
+
{ hex: 'ff d8 ff e2', off: 0, type: 'image/jpeg' },
|
|
118
|
+
{ hex: 'ff d8 ff ee', off: 0, type: 'image/jpeg' },
|
|
119
|
+
{ hex: 'ff d8 ff db', off: 0, type: 'image/jpeg' },
|
|
120
|
+
{ hex: '89 50 4e 47', off: 0, type: 'image/png' },
|
|
121
|
+
{ hex: '47 49 46 38', off: 0, type: 'image/gif' },
|
|
122
|
+
{ hex: '52 49 46 46 ?? ?? ?? ?? 57 45 42 50', off: 0, type: 'image/webp' },
|
|
123
|
+
{ hex: '4a 58 4c 20 0d 0a 87 0a', off: 8, type: 'image/jxl' },
|
|
124
|
+
{ hex: '66 74 79 70 68 65 69 63', off: 8, type: 'image/heic' },
|
|
125
|
+
{ hex: '66 74 79 70 61 76 69 66', off: 8, type: 'image/avif' },
|
|
126
|
+
{ hex: '52 49 46 46 ?? ?? ?? ?? 41 56 49 20', off: 0, type: 'video/avi' },
|
|
127
|
+
{ hex: '52 49 46 46 ?? ?? ?? ?? 57 41 56 45', off: 0, type: 'audio/wav' }
|
|
110
128
|
/*
|
|
111
129
|
When adding a new signature, include a copyright-free sample file in the `.tests` directory
|
|
112
|
-
and update the
|
|
130
|
+
and update the `signatures` test group in `Storage.test.ts`.
|
|
113
131
|
*/
|
|
114
|
-
]
|
|
132
|
+
].map((signature: Signature) => {
|
|
133
|
+
signature.hex = signature.hex.replaceAll(' ', '')
|
|
134
|
+
|
|
135
|
+
if (signature.hex.includes('??')) {
|
|
136
|
+
const expression = signature.hex.replaceAll(/(?<wildcards>\?{1,24})/g,
|
|
137
|
+
(_, wildcards) => `[0-9a-f]{${wildcards.length}}`)
|
|
138
|
+
|
|
139
|
+
signature.expression = new RegExp(expression, 'i')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return signature
|
|
143
|
+
})
|
|
115
144
|
|
|
116
145
|
const HEADER_SIZE = SIGNATURES
|
|
117
146
|
.reduce((max, { off, hex }) => Math.max(max, off + hex.length), 0) / 2
|
|
118
147
|
|
|
119
148
|
const KNOWN_TYPES = new Set(SIGNATURES.map(({ type }) => type))
|
|
120
149
|
|
|
121
|
-
const ERR_TYPE_MISMATCH = Err('TYPE_MISMATCH')
|
|
122
|
-
const ERR_NOT_ACCEPTABLE = Err('NOT_ACCEPTABLE')
|
|
150
|
+
const ERR_TYPE_MISMATCH = new Err('TYPE_MISMATCH')
|
|
151
|
+
const ERR_NOT_ACCEPTABLE = new Err('NOT_ACCEPTABLE')
|
|
152
|
+
const ERR_LIMIT_EXCEEDED = new Err('LIMIT_EXCEEDED')
|
|
123
153
|
|
|
124
154
|
export interface ScanOptions {
|
|
125
155
|
claim?: string
|
|
126
156
|
accept?: string
|
|
157
|
+
limit?: number
|
|
127
158
|
}
|
|
128
159
|
|
|
129
160
|
interface Signature {
|
|
130
|
-
hex: string
|
|
131
|
-
off: number
|
|
132
161
|
type: string
|
|
162
|
+
off: number
|
|
163
|
+
hex: string
|
|
164
|
+
expression?: RegExp
|
|
133
165
|
}
|