@toa.io/extensions.storages 1.0.0-alpha.20 → 1.0.0-alpha.202

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 (91) 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 +22 -19
  11. package/source/Scanner.ts +58 -26
  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.wav +0 -0
  34. package/source/test/sport.mp4 +0 -0
  35. package/source/test/util.ts +62 -12
  36. package/transpiled/Entry.d.ts +15 -11
  37. package/transpiled/Factory.js +11 -5
  38. package/transpiled/Factory.js.map +1 -1
  39. package/transpiled/Provider.d.ts +17 -16
  40. package/transpiled/Provider.js +6 -4
  41. package/transpiled/Provider.js.map +1 -1
  42. package/transpiled/Scanner.d.ts +6 -2
  43. package/transpiled/Scanner.js +49 -22
  44. package/transpiled/Scanner.js.map +1 -1
  45. package/transpiled/Secrets.d.ts +5 -0
  46. package/transpiled/Secrets.js +3 -0
  47. package/transpiled/Secrets.js.map +1 -0
  48. package/transpiled/Storage.d.ts +13 -21
  49. package/transpiled/Storage.js +52 -155
  50. package/transpiled/Storage.js.map +1 -1
  51. package/transpiled/deployment.d.ts +1 -1
  52. package/transpiled/deployment.js +43 -16
  53. package/transpiled/deployment.js.map +1 -1
  54. package/transpiled/errors.d.ts +2 -0
  55. package/transpiled/errors.js +6 -0
  56. package/transpiled/errors.js.map +1 -0
  57. package/transpiled/index.d.ts +3 -1
  58. package/transpiled/manifest.js +5 -2
  59. package/transpiled/manifest.js.map +1 -1
  60. package/transpiled/providers/Cloudinary.d.ts +51 -0
  61. package/transpiled/providers/Cloudinary.js +223 -0
  62. package/transpiled/providers/Cloudinary.js.map +1 -0
  63. package/transpiled/providers/Declaration.d.ts +3 -2
  64. package/transpiled/providers/FileSystem.d.ts +15 -7
  65. package/transpiled/providers/FileSystem.js +64 -24
  66. package/transpiled/providers/FileSystem.js.map +1 -1
  67. package/transpiled/providers/S3.d.ts +14 -14
  68. package/transpiled/providers/S3.js +62 -60
  69. package/transpiled/providers/S3.js.map +1 -1
  70. package/transpiled/providers/Temporary.d.ts +1 -2
  71. package/transpiled/providers/Temporary.js +2 -2
  72. package/transpiled/providers/Temporary.js.map +1 -1
  73. package/transpiled/providers/Test.d.ts +3 -3
  74. package/transpiled/providers/Test.js +2 -2
  75. package/transpiled/providers/Test.js.map +1 -1
  76. package/transpiled/providers/index.d.ts +7 -2
  77. package/transpiled/providers/index.js +2 -2
  78. package/transpiled/providers/index.js.map +1 -1
  79. package/transpiled/schemas.d.ts +1 -0
  80. package/transpiled/schemas.js +2 -1
  81. package/transpiled/schemas.js.map +1 -1
  82. package/transpiled/test/util.d.ts +89 -5
  83. package/transpiled/test/util.js +51 -4
  84. package/transpiled/test/util.js.map +1 -1
  85. package/transpiled/tsconfig.tsbuildinfo +1 -1
  86. package/source/providers/FileSystem.test.ts +0 -5
  87. package/source/providers/Memory.ts +0 -41
  88. package/source/providers/S3.test.ts +0 -133
  89. package/transpiled/providers/Memory.d.ts +0 -13
  90. package/transpiled/providers/Memory.js +0 -60
  91. 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.20",
3
+ "version": "1.0.0-alpha.202",
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.20",
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.20",
35
- "@toa.io/generic": "1.0.0-alpha.20",
36
- "@toa.io/schemas": "1.0.0-alpha.20",
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": "dcde3389a86ab38e377ebc5ab18e5d5a512b85bc"
42
+ "gitHead": "4d230bb899fee0cd49b099db09749a01d01cda7e"
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'
3
6
 
4
- export type ProviderSecrets<K extends string = string> = Record<K | string, string | undefined>
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
- 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[] = []
12
+ protected constructor (options: Options, secrets?: Secrets) {
13
+ this.options = options
13
14
 
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}'`)
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<Readable | null>
19
+ public abstract get (path: string, options?: unknown): Promise<Maybe<Stream>>
20
+
21
+ public abstract head (path: string): Promise<Maybe<Metadata>>
22
+
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,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.detected)
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, off }) => header.slice(off, off + hex.length) === 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.detected = true
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.end()
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: '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' }
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 'signatures' test group in `Storage.test.ts`.
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
  }
@@ -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
+ }