@ultimabase/files 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +4 -2
- package/dist/field.d.ts +89 -0
- package/dist/field.d.ts.map +1 -0
- package/dist/field.js +123 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/lifecycle.d.ts +50 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +192 -0
- package/dist/memory.d.ts +16 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +27 -0
- package/dist/model.d.ts +41 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +28 -0
- package/dist/plugin.d.ts +39 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +80 -0
- package/dist/routes.d.ts +4 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +32 -0
- package/dist/s3.d.ts +17 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +99 -0
- package/dist/shape.d.ts +17 -0
- package/dist/shape.d.ts.map +1 -0
- package/dist/shape.js +59 -0
- package/dist/sigv4.d.ts +18 -0
- package/dist/sigv4.d.ts.map +1 -0
- package/dist/sigv4.js +49 -0
- package/dist/standard.d.ts +21 -0
- package/dist/standard.d.ts.map +1 -0
- package/dist/standard.js +1 -0
- package/dist/storage.d.ts +30 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +16 -0
- package/dist/upload.d.ts +19 -0
- package/dist/upload.d.ts.map +1 -0
- package/dist/upload.js +206 -0
- package/dist/values.d.ts +81 -0
- package/dist/values.d.ts.map +1 -0
- package/dist/values.js +125 -0
- package/package.json +46 -3
- package/src/field.ts +236 -0
- package/src/index.ts +22 -0
- package/src/lifecycle.ts +270 -0
- package/src/memory.ts +46 -0
- package/src/model.ts +67 -0
- package/src/plugin.ts +144 -0
- package/src/routes.ts +42 -0
- package/src/s3.ts +141 -0
- package/src/shape.ts +83 -0
- package/src/sigv4.ts +81 -0
- package/src/standard.ts +15 -0
- package/src/storage.ts +46 -0
- package/src/upload.ts +282 -0
- package/src/values.ts +213 -0
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// `filesPlugin({ storage, access })` (D-152): the plugin of the `file` field
|
|
2
|
+
// type, which runs its hooks on every model holding a file field, owns
|
|
3
|
+
// `FilesBlob`, sweeps what a crash left and serves `POST /files`.
|
|
4
|
+
import type { Http } from '@ultimabase/http'
|
|
5
|
+
import { httpPlugin } from '@ultimabase/http'
|
|
6
|
+
import type {
|
|
7
|
+
CallerRule,
|
|
8
|
+
ExposedModel,
|
|
9
|
+
Identity,
|
|
10
|
+
PluginOf,
|
|
11
|
+
PluginTypes,
|
|
12
|
+
} from 'ultimabase'
|
|
13
|
+
import { definePlugin } from 'ultimabase'
|
|
14
|
+
import manifest from '../package.json' with { type: 'json' }
|
|
15
|
+
import type { FileOptions } from './field.ts'
|
|
16
|
+
import type { Checked } from './lifecycle.ts'
|
|
17
|
+
import {
|
|
18
|
+
attach,
|
|
19
|
+
check,
|
|
20
|
+
deleteHeld,
|
|
21
|
+
deleteLost,
|
|
22
|
+
release,
|
|
23
|
+
resolve,
|
|
24
|
+
sweep,
|
|
25
|
+
} from './lifecycle.ts'
|
|
26
|
+
import { FilesBlob } from './model.ts'
|
|
27
|
+
import type { FileBody, FileStorage } from './storage.ts'
|
|
28
|
+
import { uploads } from './routes.ts'
|
|
29
|
+
import { upload } from './upload.ts'
|
|
30
|
+
import { runningOf, useRunning } from './values.ts'
|
|
31
|
+
|
|
32
|
+
/** What `filesPlugin` takes (D-152). */
|
|
33
|
+
export interface FilesOptions {
|
|
34
|
+
readonly storage: FileStorage
|
|
35
|
+
readonly access?: {
|
|
36
|
+
/** A signed-in user by default; Master always may. */
|
|
37
|
+
readonly upload?: CallerRule
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The files plugin's types (D-57). */
|
|
42
|
+
export interface Files extends PluginTypes {
|
|
43
|
+
readonly name: 'files'
|
|
44
|
+
readonly appOptions: FilesOptions
|
|
45
|
+
readonly requires: Http
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Ten minutes, between two sweeps. */
|
|
49
|
+
const sweepEvery = 10 * 60 * 1000
|
|
50
|
+
|
|
51
|
+
/** Each model's file fields, with the options `file()` checked. */
|
|
52
|
+
function fieldsOf(
|
|
53
|
+
models: readonly ExposedModel[],
|
|
54
|
+
): ReadonlyMap<string, ReadonlyMap<string, FileOptions>> {
|
|
55
|
+
const fields = new Map<string, ReadonlyMap<string, FileOptions>>()
|
|
56
|
+
for (const model of models) {
|
|
57
|
+
const own = Object.entries(model.fieldTypes).flatMap(([field, type]) =>
|
|
58
|
+
type.plugin === 'files'
|
|
59
|
+
? [[field, type.options as FileOptions] as const]
|
|
60
|
+
: [],
|
|
61
|
+
)
|
|
62
|
+
if (own.length > 0) fields.set(model.schema.name, new Map(own))
|
|
63
|
+
}
|
|
64
|
+
return fields
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const signedIn: CallerRule = ({ caller }) => caller.kind === 'user'
|
|
68
|
+
|
|
69
|
+
// The sweep's timer, while the application runs.
|
|
70
|
+
let timer: NodeJS.Timeout | undefined
|
|
71
|
+
|
|
72
|
+
/** Sweeps, then again every ten minutes; a failed sweep is logged. */
|
|
73
|
+
async function sweeping(): Promise<void> {
|
|
74
|
+
try {
|
|
75
|
+
await sweep()
|
|
76
|
+
} catch (error) {
|
|
77
|
+
runningOf().logger.warn({ err: error }, 'The files sweep failed.')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const definition = definePlugin<Files>({
|
|
82
|
+
name: 'files',
|
|
83
|
+
manifest,
|
|
84
|
+
requires: { http: httpPlugin },
|
|
85
|
+
models: [FilesBlob],
|
|
86
|
+
api: ({ plugins }) => {
|
|
87
|
+
plugins.http.mount(uploads(), { bodyLimit: false })
|
|
88
|
+
},
|
|
89
|
+
hooks: {
|
|
90
|
+
beforeCreate: (facts) => resolve(facts),
|
|
91
|
+
beforeUpdate: (facts) => resolve(facts),
|
|
92
|
+
afterCreate: (facts) => attach(facts),
|
|
93
|
+
afterUpdate: (facts) => attach(facts),
|
|
94
|
+
afterDelete: (facts) => release(facts),
|
|
95
|
+
afterUpdateCommit: (facts) => deleteLost(facts),
|
|
96
|
+
afterDeleteCommit: (facts) => deleteHeld(facts),
|
|
97
|
+
},
|
|
98
|
+
start: async ({ options, models, logger }) => {
|
|
99
|
+
const { storage, access } = options
|
|
100
|
+
storage.start()
|
|
101
|
+
const fields = fieldsOf(models)
|
|
102
|
+
useRunning({ storage, upload: access?.upload ?? signedIn, fields, logger })
|
|
103
|
+
await sweeping()
|
|
104
|
+
timer = setInterval(() => void sweeping(), sweepEvery)
|
|
105
|
+
timer.unref()
|
|
106
|
+
},
|
|
107
|
+
stop: () => {
|
|
108
|
+
clearInterval(timer)
|
|
109
|
+
timer = undefined
|
|
110
|
+
useRunning(undefined)
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
/** Files for the application's file fields; it requires `httpPlugin()`. */
|
|
115
|
+
export const filesPlugin: PluginOf<Files> = definition.plugin
|
|
116
|
+
|
|
117
|
+
/** What `files.upload` takes. */
|
|
118
|
+
export interface UploadArgs {
|
|
119
|
+
/** The field the file is for: `Post.cover`. */
|
|
120
|
+
readonly for: string
|
|
121
|
+
readonly name: string
|
|
122
|
+
readonly as?: Identity
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The running files plugin, from server code (D-152). */
|
|
126
|
+
export const files: {
|
|
127
|
+
/** Stores a file as `POST /files` does, and gives its id. */
|
|
128
|
+
upload(
|
|
129
|
+
body: Uint8Array | Blob,
|
|
130
|
+
args: UploadArgs,
|
|
131
|
+
): Promise<{ readonly id: string }>
|
|
132
|
+
/** Compares the storage's keys with `FilesBlob`. */
|
|
133
|
+
check(options?: { readonly delete?: boolean }): Promise<Checked>
|
|
134
|
+
} = {
|
|
135
|
+
upload: (body, args) => {
|
|
136
|
+
const given: FileBody = body instanceof Uint8Array ? body : body.stream()
|
|
137
|
+
const type = body instanceof Uint8Array ? undefined : body.type
|
|
138
|
+
return upload(given, body instanceof Uint8Array ? body.length : body.size, {
|
|
139
|
+
...args,
|
|
140
|
+
type,
|
|
141
|
+
})
|
|
142
|
+
},
|
|
143
|
+
check: (options) => check(options),
|
|
144
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// `POST /files?for=Post.cover&name=photo.jpg` (D-152): the file as the raw
|
|
2
|
+
// body, which its field's `maxSize` limits, not the HTTP plugin (D-153).
|
|
3
|
+
import type { Routes } from '@ultimabase/http'
|
|
4
|
+
import { routes } from '@ultimabase/http'
|
|
5
|
+
import { PublicError } from 'ultimabase'
|
|
6
|
+
import { upload } from './upload.ts'
|
|
7
|
+
|
|
8
|
+
/** An upload's own refusals, which the HTTP plugin does not know. */
|
|
9
|
+
const statuses: ReadonlyMap<string, 413 | 415> = new Map([
|
|
10
|
+
['PAYLOAD_TOO_LARGE', 413],
|
|
11
|
+
['UNSUPPORTED_MEDIA_TYPE', 415],
|
|
12
|
+
] as const)
|
|
13
|
+
|
|
14
|
+
/** The upload route, which the plugin mounts without the body limit. */
|
|
15
|
+
export const uploads = (): Routes =>
|
|
16
|
+
routes().post('/files', async (c) => {
|
|
17
|
+
const length = c.req.header('content-length')
|
|
18
|
+
if (length === undefined) {
|
|
19
|
+
return c.json(
|
|
20
|
+
{ code: 'LENGTH_REQUIRED', message: 'Content-Length is required.' },
|
|
21
|
+
411,
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const { id } = await upload(
|
|
26
|
+
c.req.raw.body ?? new Uint8Array(),
|
|
27
|
+
Number(length),
|
|
28
|
+
{
|
|
29
|
+
for: c.req.query('for') ?? '',
|
|
30
|
+
name: c.req.query('name') ?? '',
|
|
31
|
+
type: c.req.header('content-type'),
|
|
32
|
+
as: c.var.caller,
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
return c.json({ id }, 201)
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!(error instanceof PublicError)) throw error
|
|
38
|
+
const status = statuses.get(error.code)
|
|
39
|
+
if (status === undefined) throw error
|
|
40
|
+
return c.json({ code: error.code, message: error.message }, status)
|
|
41
|
+
}
|
|
42
|
+
})
|
package/src/s3.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// `s3()` (D-152): any S3-compatible storage (AWS, DigitalOcean Spaces,
|
|
2
|
+
// MinIO, SeaweedFS), path-style, each request presigned with SigV4.
|
|
3
|
+
import type { Signer } from './sigv4.ts'
|
|
4
|
+
import { presign } from './sigv4.ts'
|
|
5
|
+
import type { FileStorage } from './storage.ts'
|
|
6
|
+
import { isPublic } from './storage.ts'
|
|
7
|
+
|
|
8
|
+
/** What `s3()` takes; the first five are required, read when the application starts. */
|
|
9
|
+
export interface S3Options {
|
|
10
|
+
readonly bucket: string | undefined
|
|
11
|
+
readonly region: string | undefined
|
|
12
|
+
/** `https://fra1.digitaloceanspaces.com`. */
|
|
13
|
+
readonly endpoint: string | undefined
|
|
14
|
+
readonly accessKeyId: string | undefined
|
|
15
|
+
readonly secretAccessKey: string | undefined
|
|
16
|
+
/** Sends `x-amz-acl: public-read` with a public file, which Spaces needs. */
|
|
17
|
+
readonly publicAcl?: boolean
|
|
18
|
+
/** The base of a public file's URL, a CDN; the storage's own otherwise. */
|
|
19
|
+
readonly publicUrl?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const required = [
|
|
23
|
+
'bucket',
|
|
24
|
+
'region',
|
|
25
|
+
'endpoint',
|
|
26
|
+
'accessKeyId',
|
|
27
|
+
'secretAccessKey',
|
|
28
|
+
] as const
|
|
29
|
+
|
|
30
|
+
type Config = Signer & { readonly bucket: string; readonly base: string }
|
|
31
|
+
|
|
32
|
+
const trimmed = (url: string): string => url.replace(/\/+$/, '')
|
|
33
|
+
|
|
34
|
+
/** The options checked: a missing one stops startup, naming it. */
|
|
35
|
+
function configOf(options: S3Options): Config {
|
|
36
|
+
const missing = required.filter((key) => !options[key])
|
|
37
|
+
if (missing.length > 0) {
|
|
38
|
+
throw new Error(`s3() needs ${missing.join(', ')}: undefined or empty.`)
|
|
39
|
+
}
|
|
40
|
+
const { bucket = '', region = '', endpoint = '' } = options
|
|
41
|
+
const { accessKeyId = '', secretAccessKey = '' } = options
|
|
42
|
+
const base = `${trimmed(endpoint)}/${encodeURIComponent(bucket)}`
|
|
43
|
+
return { bucket, region, accessKeyId, secretAccessKey, base }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Sent unchanged when the storage answers otherwise. */
|
|
47
|
+
async function answered(
|
|
48
|
+
response: Response,
|
|
49
|
+
what: string,
|
|
50
|
+
expected: readonly number[] = [200],
|
|
51
|
+
): Promise<Response> {
|
|
52
|
+
if (expected.includes(response.status)) return response
|
|
53
|
+
const text = await response.text()
|
|
54
|
+
throw new Error(`s3: ${what} failed with ${response.status}: ${text}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const entities: { readonly [entity: string]: string } = {
|
|
58
|
+
amp: '&',
|
|
59
|
+
lt: '<',
|
|
60
|
+
gt: '>',
|
|
61
|
+
quot: '"',
|
|
62
|
+
apos: "'",
|
|
63
|
+
}
|
|
64
|
+
const decoded = (text: string): string =>
|
|
65
|
+
text.replaceAll(
|
|
66
|
+
/&(amp|lt|gt|quot|apos);/g,
|
|
67
|
+
(entity, name: string) => entities[name] ?? entity,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
/** The keys of one `ListObjectsV2` page, and the token of the next. */
|
|
71
|
+
function pageOf(xml: string): {
|
|
72
|
+
readonly keys: readonly string[]
|
|
73
|
+
readonly next: string | undefined
|
|
74
|
+
} {
|
|
75
|
+
const keys = [...xml.matchAll(/<Key>([^<]*)<\/Key>/g)].map(([, key = '']) =>
|
|
76
|
+
decoded(key),
|
|
77
|
+
)
|
|
78
|
+
const truncated = xml.includes('<IsTruncated>true</IsTruncated>')
|
|
79
|
+
const token = /<NextContinuationToken>([^<]*)</.exec(xml)?.[1]
|
|
80
|
+
return { keys, next: truncated && token ? decoded(token) : undefined }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** An S3-compatible storage; its options are checked when the application starts. */
|
|
84
|
+
export function s3(options: S3Options): FileStorage {
|
|
85
|
+
let config: Config | undefined
|
|
86
|
+
const configured = (): Config => (config ??= configOf(options))
|
|
87
|
+
const objectUrl = (key: string) => new URL(`${configured().base}/${key}`)
|
|
88
|
+
const signed = (
|
|
89
|
+
method: string,
|
|
90
|
+
url: URL,
|
|
91
|
+
expires: number,
|
|
92
|
+
headers: { readonly [name: string]: string } = {},
|
|
93
|
+
) => presign(configured(), { method, url, expires, headers })
|
|
94
|
+
return {
|
|
95
|
+
start: () => {
|
|
96
|
+
configured()
|
|
97
|
+
},
|
|
98
|
+
put: async (key, body, { type, size, attachment }) => {
|
|
99
|
+
const headers: Record<string, string> = { 'content-type': type }
|
|
100
|
+
if (attachment) headers['content-disposition'] = 'attachment'
|
|
101
|
+
if (isPublic(key) && options.publicAcl === true) {
|
|
102
|
+
headers['x-amz-acl'] = 'public-read'
|
|
103
|
+
}
|
|
104
|
+
const url = signed('PUT', objectUrl(key), 900, headers)
|
|
105
|
+
const response = await fetch(url, {
|
|
106
|
+
method: 'PUT',
|
|
107
|
+
headers: { ...headers, 'content-length': String(size) },
|
|
108
|
+
body,
|
|
109
|
+
duplex: 'half',
|
|
110
|
+
})
|
|
111
|
+
await answered(response, `PUT ${key}`)
|
|
112
|
+
},
|
|
113
|
+
delete: async (key) => {
|
|
114
|
+
const url = signed('DELETE', objectUrl(key), 900)
|
|
115
|
+
const response = await fetch(url, { method: 'DELETE' })
|
|
116
|
+
await answered(response, `DELETE ${key}`, [200, 204, 404])
|
|
117
|
+
},
|
|
118
|
+
keys: async () => {
|
|
119
|
+
const keys: string[] = []
|
|
120
|
+
let next: string | undefined
|
|
121
|
+
do {
|
|
122
|
+
const list = new URL(configured().base)
|
|
123
|
+
list.searchParams.set('list-type', '2')
|
|
124
|
+
if (next !== undefined)
|
|
125
|
+
list.searchParams.set('continuation-token', next)
|
|
126
|
+
const response = await fetch(signed('GET', list, 900))
|
|
127
|
+
const page = pageOf(await (await answered(response, 'list')).text())
|
|
128
|
+
keys.push(...page.keys)
|
|
129
|
+
next = page.next
|
|
130
|
+
} while (next !== undefined)
|
|
131
|
+
return keys
|
|
132
|
+
},
|
|
133
|
+
url: (key) => {
|
|
134
|
+
if (!isPublic(key)) return signed('GET', objectUrl(key), 3600)
|
|
135
|
+
const { publicUrl } = options
|
|
136
|
+
return publicUrl === undefined
|
|
137
|
+
? objectUrl(key).href
|
|
138
|
+
: `${trimmed(publicUrl)}/${key}`
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/shape.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// A file field's shape (D-152): the JSON Schema of what clients read, which
|
|
2
|
+
// REST and GraphQL type it with. It validates nothing: the plugin made the
|
|
3
|
+
// value.
|
|
4
|
+
import type { StandardSchema } from './standard.ts'
|
|
5
|
+
import type { FileOptions } from './field.ts'
|
|
6
|
+
|
|
7
|
+
type Node = { readonly [key: string]: unknown }
|
|
8
|
+
type Exported = {
|
|
9
|
+
readonly jsonSchema: {
|
|
10
|
+
readonly input: () => Node
|
|
11
|
+
readonly output: () => Node
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const isNode = (value: unknown): value is Node =>
|
|
16
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
17
|
+
|
|
18
|
+
const string = { type: 'string' }
|
|
19
|
+
const size = { type: 'integer', minimum: 0 }
|
|
20
|
+
const nullable = (node: Node): Node =>
|
|
21
|
+
Object.keys(node).length === 0 ? node : { anyOf: [node, { type: 'null' }] }
|
|
22
|
+
|
|
23
|
+
const closed = (properties: { readonly [key: string]: Node }): Node => ({
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties,
|
|
26
|
+
required: Object.keys(properties),
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/** An inline value's JSON Schema, from its schema's; any JSON without one. */
|
|
31
|
+
function inlineOf(inline: unknown): Node {
|
|
32
|
+
if (!isNode(inline) || !isNode(inline['schema'])) return {}
|
|
33
|
+
const standard = inline['schema']['~standard']
|
|
34
|
+
const exporter = isNode(standard) ? standard['jsonSchema'] : undefined
|
|
35
|
+
const output = isNode(exporter) ? exporter['output'] : undefined
|
|
36
|
+
if (typeof output !== 'function') return {}
|
|
37
|
+
const node: unknown = Reflect.apply(output, exporter, [
|
|
38
|
+
{ target: 'draft-2020-12' },
|
|
39
|
+
])
|
|
40
|
+
if (!isNode(node)) return {}
|
|
41
|
+
const { $schema: _, ...rest } = node
|
|
42
|
+
return rest
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fileNode(options: FileOptions): Node {
|
|
46
|
+
const inline = Object.entries(options.inline ?? {}).map(
|
|
47
|
+
([key, given]) => [key, nullable(inlineOf(given))] as const,
|
|
48
|
+
)
|
|
49
|
+
const variant = closed({ type: string, size, url: string })
|
|
50
|
+
const variants = Object.keys(options.variants ?? {}).map(
|
|
51
|
+
(key) => [key, nullable(variant)] as const,
|
|
52
|
+
)
|
|
53
|
+
return closed({
|
|
54
|
+
id: string,
|
|
55
|
+
name: string,
|
|
56
|
+
type: string,
|
|
57
|
+
size,
|
|
58
|
+
url: string,
|
|
59
|
+
inline: closed(Object.fromEntries(inline)),
|
|
60
|
+
variants: closed(Object.fromEntries(variants)),
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nodeOf(options: FileOptions): Node {
|
|
65
|
+
const node = fileNode(options)
|
|
66
|
+
if (options.list === true) return { type: 'array', items: node }
|
|
67
|
+
return options.optional === true ? nullable(node) : node
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What a field with `options` gives clients, as a Standard Schema. */
|
|
71
|
+
export const shapeOf = (
|
|
72
|
+
options: FileOptions,
|
|
73
|
+
): StandardSchema & { readonly '~standard': Exported } => ({
|
|
74
|
+
'~standard': {
|
|
75
|
+
version: 1,
|
|
76
|
+
vendor: 'ultimabase-files',
|
|
77
|
+
validate: (value) => ({ value }),
|
|
78
|
+
jsonSchema: {
|
|
79
|
+
input: () => nodeOf(options),
|
|
80
|
+
output: () => nodeOf(options),
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
})
|
package/src/sigv4.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// AWS Signature Version 4, query presigning (D-152): on `node:crypto`, which
|
|
2
|
+
// is synchronous, so a read signs its URLs while it decodes. The payload is
|
|
3
|
+
// unsigned, as presigned URLs are.
|
|
4
|
+
import { createHash, createHmac } from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
export interface Signer {
|
|
7
|
+
readonly region: string
|
|
8
|
+
readonly accessKeyId: string
|
|
9
|
+
readonly secretAccessKey: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Presigned {
|
|
13
|
+
readonly method: string
|
|
14
|
+
readonly url: URL
|
|
15
|
+
/** The headers the request sends and signs, `host` added. */
|
|
16
|
+
readonly headers: { readonly [name: string]: string }
|
|
17
|
+
/** In seconds. */
|
|
18
|
+
readonly expires: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** RFC 3986, as SigV4 encodes: everything but `A-Za-z0-9-_.~`. */
|
|
22
|
+
const encoded = (value: string): string =>
|
|
23
|
+
encodeURIComponent(value).replaceAll(
|
|
24
|
+
/[!'()*]/g,
|
|
25
|
+
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const hmac = (key: string | Buffer, value: string): Buffer =>
|
|
29
|
+
createHmac('sha256', key).update(value).digest()
|
|
30
|
+
|
|
31
|
+
/** `20130524T000000Z`. */
|
|
32
|
+
const stampOf = (now: Date): string =>
|
|
33
|
+
now
|
|
34
|
+
.toISOString()
|
|
35
|
+
.replaceAll(/[-:]/g, '')
|
|
36
|
+
.replace(/\.\d{3}/, '')
|
|
37
|
+
|
|
38
|
+
/** The URL that runs the request until it expires, signed now. */
|
|
39
|
+
export function presign(signer: Signer, request: Presigned): string {
|
|
40
|
+
const { method, url, expires } = request
|
|
41
|
+
const stamp = stampOf(new Date())
|
|
42
|
+
const day = stamp.slice(0, 8)
|
|
43
|
+
const scope = `${day}/${signer.region}/s3/aws4_request`
|
|
44
|
+
const headers = new Map(
|
|
45
|
+
Object.entries({ ...request.headers, host: url.host }).map(
|
|
46
|
+
([name, value]) => [name.toLowerCase(), value.trim()],
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
const names = [...headers.keys()].toSorted()
|
|
50
|
+
const signed = names.join(';')
|
|
51
|
+
const query = new URLSearchParams(url.search)
|
|
52
|
+
query.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256')
|
|
53
|
+
query.set('X-Amz-Credential', `${signer.accessKeyId}/${scope}`)
|
|
54
|
+
query.set('X-Amz-Date', stamp)
|
|
55
|
+
query.set('X-Amz-Expires', String(expires))
|
|
56
|
+
query.set('X-Amz-SignedHeaders', signed)
|
|
57
|
+
const canonicalQuery = [...query]
|
|
58
|
+
.map(([key, value]) => [encoded(key), encoded(value)])
|
|
59
|
+
.toSorted(([a = ''], [b = '']) => (a < b ? -1 : a > b ? 1 : 0))
|
|
60
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
61
|
+
.join('&')
|
|
62
|
+
const path = url.pathname
|
|
63
|
+
.split('/')
|
|
64
|
+
.map((part) => encoded(decodeURIComponent(part)))
|
|
65
|
+
const canonical = [
|
|
66
|
+
method,
|
|
67
|
+
path.join('/'),
|
|
68
|
+
canonicalQuery,
|
|
69
|
+
names.map((name) => `${name}:${headers.get(name)}\n`).join(''),
|
|
70
|
+
signed,
|
|
71
|
+
'UNSIGNED-PAYLOAD',
|
|
72
|
+
].join('\n')
|
|
73
|
+
const digest = createHash('sha256').update(canonical).digest('hex')
|
|
74
|
+
const toSign = ['AWS4-HMAC-SHA256', stamp, scope, digest].join('\n')
|
|
75
|
+
const key = ['s3', 'aws4_request'].reduce(
|
|
76
|
+
hmac,
|
|
77
|
+
hmac(hmac(`AWS4${signer.secretAccessKey}`, day), signer.region),
|
|
78
|
+
)
|
|
79
|
+
const signature = createHmac('sha256', key).update(toSign).digest('hex')
|
|
80
|
+
return `${url.origin}${path.join('/')}?${canonicalQuery}&X-Amz-Signature=${signature}`
|
|
81
|
+
}
|
package/src/standard.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Standard Schema (standardschema.dev), the part this package reads. Its
|
|
2
|
+
// specification asks for the interface to be copied, not imported.
|
|
3
|
+
export interface StandardSchema<I = unknown, O = I> {
|
|
4
|
+
readonly '~standard': {
|
|
5
|
+
readonly version: 1
|
|
6
|
+
readonly vendor: string
|
|
7
|
+
readonly validate: (
|
|
8
|
+
value: unknown,
|
|
9
|
+
) => SchemaResult<O> | Promise<SchemaResult<O>>
|
|
10
|
+
readonly types?: { readonly input: I; readonly output: O } | undefined
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
type SchemaResult<O> =
|
|
14
|
+
| { readonly value: O; readonly issues?: undefined }
|
|
15
|
+
| { readonly issues: readonly { readonly message: string }[] }
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// What `filesPlugin` stores files in (D-152): an S3-compatible storage, or
|
|
2
|
+
// memory for tests. A key starts with `public/` or `private/`, which says
|
|
3
|
+
// how the object is served.
|
|
4
|
+
|
|
5
|
+
/** A file's bytes: streamed from a request, or held in memory. */
|
|
6
|
+
export type FileBody = ReadableStream<Uint8Array> | Uint8Array
|
|
7
|
+
|
|
8
|
+
/** How an object is stored and served. */
|
|
9
|
+
export interface PutOptions {
|
|
10
|
+
readonly type: string
|
|
11
|
+
/** The body's length in bytes, which the storage checks. */
|
|
12
|
+
readonly size: number
|
|
13
|
+
/** Served as a download, so a browser never runs it: SVG, undetected types. */
|
|
14
|
+
readonly attachment: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A storage adapter: `s3()` or `memoryFiles()`. */
|
|
18
|
+
export interface FileStorage {
|
|
19
|
+
/** Checks its options when the application starts. */
|
|
20
|
+
start(): void
|
|
21
|
+
put(key: string, body: FileBody, options: PutOptions): Promise<void>
|
|
22
|
+
/** Deletes an object; a missing one is not an error. */
|
|
23
|
+
delete(key: string): Promise<void>
|
|
24
|
+
/** Every key it stores. */
|
|
25
|
+
keys(): Promise<readonly string[]>
|
|
26
|
+
/** A public object's stable URL, a private one's presigned for one hour. */
|
|
27
|
+
url(key: string): string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether a key is served to everyone. */
|
|
31
|
+
export const isPublic = (key: string): boolean => key.startsWith('public/')
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Each promise ended, then the first failure thrown: a put still running
|
|
35
|
+
* would store an object after its cleanup.
|
|
36
|
+
*/
|
|
37
|
+
export async function settled<T>(
|
|
38
|
+
promises: readonly Promise<T>[],
|
|
39
|
+
): Promise<T[]> {
|
|
40
|
+
const results = await Promise.allSettled(promises)
|
|
41
|
+
const failed = results.find((result) => result.status === 'rejected')
|
|
42
|
+
if (failed !== undefined) throw failed.reason
|
|
43
|
+
return results.flatMap((result) =>
|
|
44
|
+
result.status === 'fulfilled' ? [result.value] : [],
|
|
45
|
+
)
|
|
46
|
+
}
|