@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.
Files changed (92) hide show
  1. package/package.json +14 -14
  2. package/readme.md +96 -51
  3. package/schemas/annotation.cos.yaml +1 -0
  4. package/schemas/cloudinary.cos.yaml +37 -0
  5. package/schemas/fs.cos.yaml +2 -0
  6. package/schemas/s3.cos.yaml +1 -0
  7. package/schemas/tmp.cos.yaml +0 -1
  8. package/source/Entry.ts +13 -11
  9. package/source/Factory.ts +16 -9
  10. package/source/Provider.ts +24 -21
  11. package/source/Scanner.ts +57 -24
  12. package/source/Secrets.ts +6 -0
  13. package/source/Storage.test.ts +118 -274
  14. package/source/Storage.ts +62 -207
  15. package/source/deployment.ts +52 -16
  16. package/source/errors.ts +3 -0
  17. package/source/index.ts +3 -1
  18. package/source/manifest.ts +7 -6
  19. package/source/providers/Cloudinary.ts +320 -0
  20. package/source/providers/Declaration.ts +2 -1
  21. package/source/providers/FileSystem.ts +79 -25
  22. package/source/providers/S3.ts +81 -80
  23. package/source/providers/Temporary.ts +2 -3
  24. package/source/providers/Test.ts +4 -4
  25. package/source/providers/index.test.ts +102 -76
  26. package/source/providers/index.ts +10 -4
  27. package/source/schemas.ts +1 -0
  28. package/source/test/.env.example +4 -0
  29. package/source/test/arny.jpg +0 -0
  30. package/source/test/lenna.48x48.jpeg +0 -0
  31. package/source/test/plank.mp4 +0 -0
  32. package/source/test/sample.avi +0 -0
  33. package/source/test/sample.svg +4 -0
  34. package/source/test/sample.wav +0 -0
  35. package/source/test/sport.mp4 +0 -0
  36. package/source/test/util.ts +62 -12
  37. package/transpiled/Entry.d.ts +15 -11
  38. package/transpiled/Factory.js +11 -5
  39. package/transpiled/Factory.js.map +1 -1
  40. package/transpiled/Provider.d.ts +17 -16
  41. package/transpiled/Provider.js +6 -4
  42. package/transpiled/Provider.js.map +1 -1
  43. package/transpiled/Scanner.d.ts +6 -2
  44. package/transpiled/Scanner.js +49 -22
  45. package/transpiled/Scanner.js.map +1 -1
  46. package/transpiled/Secrets.d.ts +5 -0
  47. package/transpiled/Secrets.js +3 -0
  48. package/transpiled/Secrets.js.map +1 -0
  49. package/transpiled/Storage.d.ts +13 -21
  50. package/transpiled/Storage.js +52 -155
  51. package/transpiled/Storage.js.map +1 -1
  52. package/transpiled/deployment.d.ts +1 -1
  53. package/transpiled/deployment.js +43 -16
  54. package/transpiled/deployment.js.map +1 -1
  55. package/transpiled/errors.d.ts +2 -0
  56. package/transpiled/errors.js +6 -0
  57. package/transpiled/errors.js.map +1 -0
  58. package/transpiled/index.d.ts +3 -1
  59. package/transpiled/manifest.js +5 -2
  60. package/transpiled/manifest.js.map +1 -1
  61. package/transpiled/providers/Cloudinary.d.ts +51 -0
  62. package/transpiled/providers/Cloudinary.js +223 -0
  63. package/transpiled/providers/Cloudinary.js.map +1 -0
  64. package/transpiled/providers/Declaration.d.ts +3 -2
  65. package/transpiled/providers/FileSystem.d.ts +15 -7
  66. package/transpiled/providers/FileSystem.js +64 -24
  67. package/transpiled/providers/FileSystem.js.map +1 -1
  68. package/transpiled/providers/S3.d.ts +14 -14
  69. package/transpiled/providers/S3.js +62 -60
  70. package/transpiled/providers/S3.js.map +1 -1
  71. package/transpiled/providers/Temporary.d.ts +1 -2
  72. package/transpiled/providers/Temporary.js +2 -2
  73. package/transpiled/providers/Temporary.js.map +1 -1
  74. package/transpiled/providers/Test.d.ts +3 -3
  75. package/transpiled/providers/Test.js +2 -2
  76. package/transpiled/providers/Test.js.map +1 -1
  77. package/transpiled/providers/index.d.ts +7 -2
  78. package/transpiled/providers/index.js +2 -2
  79. package/transpiled/providers/index.js.map +1 -1
  80. package/transpiled/schemas.d.ts +1 -0
  81. package/transpiled/schemas.js +2 -1
  82. package/transpiled/schemas.js.map +1 -1
  83. package/transpiled/test/util.d.ts +89 -5
  84. package/transpiled/test/util.js +51 -4
  85. package/transpiled/test/util.js.map +1 -1
  86. package/transpiled/tsconfig.tsbuildinfo +1 -1
  87. package/source/providers/FileSystem.test.ts +0 -5
  88. package/source/providers/Memory.ts +0 -41
  89. package/source/providers/S3.test.ts +0 -133
  90. package/transpiled/providers/Memory.d.ts +0 -13
  91. package/transpiled/providers/Memory.js +0 -60
  92. 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.2",
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
- "devDependencies": {
28
- "@toa.io/streams": "1.0.0-alpha.2",
29
- "dotenv": "16.3.1"
27
+ "peerDependencies": {
28
+ "dotenv": "*"
30
29
  },
31
30
  "dependencies": {
32
- "@aws-sdk/client-s3": "3.481.0",
33
- "@aws-sdk/lib-storage": "3.481.0",
34
- "@toa.io/agent": "1.0.0-alpha.2",
35
- "@toa.io/generic": "1.0.0-alpha.2",
36
- "@toa.io/schemas": "1.0.0-alpha.2",
37
- "error-value": "0.3.0",
38
- "matchacho": "0.6.0",
39
- "msgpackr": "1.10.1",
40
- "smithy-node-native-fetch": "1.0.6"
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": "7688e6e980a65c82ac2e459be4e355eebf406cd0"
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
- - `id` - checksum
10
- - `size` - size in bytes
11
- - `type` - MIME type
12
- - `created` - creation timestamp (UNIX time, ms)
13
- - `variants` - array of:
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
- variants:
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.fetch('/path/to/b4f577e0.thumbnail.jpeg')
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
- meta?: Record<string, string>
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
- If the entry already exists, it is returned and [revealed](#async-revealpath-string-maybevoid).
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 fetch(path: string): Maybe<Readable>`
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 list(path: string): string[]`
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
- #### `async reveal(path: string): Maybe<void>`
91
+ Moves the entry specified by `path` to the new path.
120
92
 
121
- Restore the entry to the list.
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
- #### `async annotate(path: string, key: string, value: any): Maybe<void>`
96
+ The following examples are equivalent:
124
97
 
125
- Set a `key` property in the `meta` of the entry specified by `path`.
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 is like:
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@dev:
197
+ photos:
164
198
  provider: fs
165
- path: /var/my-storage
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
- .list # list of entries
253
+ /.entries/
254
+ b4f577e0 # entry
209
255
  /b4f577e0
210
- .meta # entry
211
- thumbnail.jpeg # variant BLOBs
256
+ thumbnail.jpeg # variant
212
257
  thumbnail.webp
213
258
  ```
214
259
 
@@ -3,6 +3,7 @@ patternProperties:
3
3
  '^[a-z0-9_]{1,32}$':
4
4
  oneOf:
5
5
  - $ref: s3
6
+ - $ref: cloudinary
6
7
  - $ref: fs
7
8
  - $ref: tmp
8
9
  - $ref: mem
@@ -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
@@ -5,5 +5,7 @@ properties:
5
5
  path:
6
6
  type: string
7
7
  maxLength: 4096
8
+ claim:
9
+ type: string
8
10
  required: [provider, path]
9
11
  additionalProperties: false
@@ -14,3 +14,4 @@ properties:
14
14
  type: string
15
15
  format: uri
16
16
  required: [provider, bucket]
17
+ additionalProperties: false
@@ -1,7 +1,6 @@
1
1
  type: object
2
2
  properties:
3
3
  provider:
4
- type: string
5
4
  const: tmp
6
5
  directory:
7
6
  type: string
package/source/Entry.ts CHANGED
@@ -1,14 +1,16 @@
1
- export interface Entry {
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
- interface Variant {
11
- name: string
12
- size: number
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 { SERIALIZATION_PREFIX } from './deployment'
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 { ProviderConstructor, ProviderSecrets } from './Provider'
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.TOA_STORAGES
18
+ const env = process.env[ENV_PREFIX]
17
19
 
18
- assert.ok(env !== undefined, 'TOA_STORAGES is not defined')
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: providerId, ...options } = declaration
42
- const Provider: ProviderConstructor = providers[providerId]
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 = `${SERIALIZATION_PREFIX}_${storageName}_${secret.name}`.toUpperCase()
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,
@@ -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
- export type ProviderSecrets<K extends string = string> = Record<K | string, string | undefined>
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 constructor (_: Options, secrets?: ProviderSecrets) {
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 get (path: string): Promise<Readable | null>
23
+ public abstract put (path: string, stream: Readable): Promise<void>
20
24
 
21
- public abstract put (path: string, filename: string, stream: Readable): Promise<void>
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 ProviderConstructor {
29
- readonly SECRETS: readonly ProviderSecret[]
30
- prototype: Provider
32
+ export interface Constructor<Options = any> {
33
+ SECRETS?: readonly Secret[]
31
34
 
32
- new (options: any, secrets?: ProviderSecrets): Provider
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: Error | null = null
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 detected = false
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.detected)
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, off }) => header.slice(off, off + hex.length) === 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.detected = true
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.end()
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: 'ffd8ffe0', off: 0, type: 'image/jpeg' },
101
- { hex: 'ffd8ffe1', off: 0, type: 'image/jpeg' },
102
- { hex: 'ffd8ffee', off: 0, type: 'image/jpeg' },
103
- { hex: 'ffd8ffdb', off: 0, type: 'image/jpeg' },
104
- { hex: '89504e47', off: 0, type: 'image/png' },
105
- { hex: '47494638', off: 0, type: 'image/gif' },
106
- { hex: '52494646', off: 0, type: 'image/webp' },
107
- { hex: '4a584c200d0a870a', off: 8, type: 'image/jxl' },
108
- { hex: '6674797068656963', off: 8, type: 'image/heic' },
109
- { hex: '6674797061766966', off: 8, type: 'image/avif' }
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 'signatures' test group in `Storage.test.ts`.
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
  }
@@ -0,0 +1,6 @@
1
+ export type Secrets<K extends string = string> = Record<K | string, string | undefined>
2
+
3
+ export interface Secret {
4
+ readonly name: string
5
+ readonly optional?: boolean
6
+ }