@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +4 -2
  3. package/dist/field.d.ts +89 -0
  4. package/dist/field.d.ts.map +1 -0
  5. package/dist/field.js +123 -0
  6. package/dist/index.d.ts +12 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +4 -0
  9. package/dist/lifecycle.d.ts +50 -0
  10. package/dist/lifecycle.d.ts.map +1 -0
  11. package/dist/lifecycle.js +192 -0
  12. package/dist/memory.d.ts +16 -0
  13. package/dist/memory.d.ts.map +1 -0
  14. package/dist/memory.js +27 -0
  15. package/dist/model.d.ts +41 -0
  16. package/dist/model.d.ts.map +1 -0
  17. package/dist/model.js +28 -0
  18. package/dist/plugin.d.ts +39 -0
  19. package/dist/plugin.d.ts.map +1 -0
  20. package/dist/plugin.js +80 -0
  21. package/dist/routes.d.ts +4 -0
  22. package/dist/routes.d.ts.map +1 -0
  23. package/dist/routes.js +32 -0
  24. package/dist/s3.d.ts +17 -0
  25. package/dist/s3.d.ts.map +1 -0
  26. package/dist/s3.js +99 -0
  27. package/dist/shape.d.ts +17 -0
  28. package/dist/shape.d.ts.map +1 -0
  29. package/dist/shape.js +59 -0
  30. package/dist/sigv4.d.ts +18 -0
  31. package/dist/sigv4.d.ts.map +1 -0
  32. package/dist/sigv4.js +49 -0
  33. package/dist/standard.d.ts +21 -0
  34. package/dist/standard.d.ts.map +1 -0
  35. package/dist/standard.js +1 -0
  36. package/dist/storage.d.ts +30 -0
  37. package/dist/storage.d.ts.map +1 -0
  38. package/dist/storage.js +16 -0
  39. package/dist/upload.d.ts +19 -0
  40. package/dist/upload.d.ts.map +1 -0
  41. package/dist/upload.js +206 -0
  42. package/dist/values.d.ts +81 -0
  43. package/dist/values.d.ts.map +1 -0
  44. package/dist/values.js +125 -0
  45. package/package.json +46 -3
  46. package/src/field.ts +236 -0
  47. package/src/index.ts +22 -0
  48. package/src/lifecycle.ts +270 -0
  49. package/src/memory.ts +46 -0
  50. package/src/model.ts +67 -0
  51. package/src/plugin.ts +144 -0
  52. package/src/routes.ts +42 -0
  53. package/src/s3.ts +141 -0
  54. package/src/shape.ts +83 -0
  55. package/src/sigv4.ts +81 -0
  56. package/src/standard.ts +15 -0
  57. package/src/storage.ts +46 -0
  58. package/src/upload.ts +282 -0
  59. package/src/values.ts +213 -0
@@ -0,0 +1,39 @@
1
+ import type { Http } from '@ultimabase/http';
2
+ import type { CallerRule, Identity, PluginOf, PluginTypes } from 'ultimabase';
3
+ import type { Checked } from './lifecycle.ts';
4
+ import type { FileStorage } from './storage.ts';
5
+ /** What `filesPlugin` takes (D-152). */
6
+ export interface FilesOptions {
7
+ readonly storage: FileStorage;
8
+ readonly access?: {
9
+ /** A signed-in user by default; Master always may. */
10
+ readonly upload?: CallerRule;
11
+ };
12
+ }
13
+ /** The files plugin's types (D-57). */
14
+ export interface Files extends PluginTypes {
15
+ readonly name: 'files';
16
+ readonly appOptions: FilesOptions;
17
+ readonly requires: Http;
18
+ }
19
+ /** Files for the application's file fields; it requires `httpPlugin()`. */
20
+ export declare const filesPlugin: PluginOf<Files>;
21
+ /** What `files.upload` takes. */
22
+ export interface UploadArgs {
23
+ /** The field the file is for: `Post.cover`. */
24
+ readonly for: string;
25
+ readonly name: string;
26
+ readonly as?: Identity;
27
+ }
28
+ /** The running files plugin, from server code (D-152). */
29
+ export declare const files: {
30
+ /** Stores a file as `POST /files` does, and gives its id. */
31
+ upload(body: Uint8Array | Blob, args: UploadArgs): Promise<{
32
+ readonly id: string;
33
+ }>;
34
+ /** Compares the storage's keys with `FilesBlob`. */
35
+ check(options?: {
36
+ readonly delete?: boolean;
37
+ }): Promise<Checked>;
38
+ };
39
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAA;AAE5C,OAAO,KAAK,EACX,UAAU,EAEV,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,MAAM,YAAY,CAAA;AAInB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAW7C,OAAO,KAAK,EAAY,WAAW,EAAE,MAAM,cAAc,CAAA;AAKzD,wCAAwC;AACxC,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAA;IAC7B,QAAQ,CAAC,MAAM,CAAC,EAAE;QACjB,sDAAsD;QACtD,QAAQ,CAAC,MAAM,CAAC,EAAE,UAAU,CAAA;KAC5B,CAAA;CACD;AAED,uCAAuC;AACvC,MAAM,WAAW,KAAM,SAAQ,WAAW;IACzC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,UAAU,EAAE,YAAY,CAAA;IACjC,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAA;CACvB;AAoED,2EAA2E;AAC3E,eAAO,MAAM,WAAW,EAAE,QAAQ,CAAC,KAAK,CAAqB,CAAA;AAE7D,iCAAiC;AACjC,MAAM,WAAW,UAAU;IAC1B,+CAA+C;IAC/C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,CAAC,EAAE,QAAQ,CAAA;CACtB;AAED,0DAA0D;AAC1D,eAAO,MAAM,KAAK,EAAE;IACnB,6DAA6D;IAC7D,MAAM,CACL,IAAI,EAAE,UAAU,GAAG,IAAI,EACvB,IAAI,EAAE,UAAU,GACd,OAAO,CAAC;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACnC,oDAAoD;IACpD,KAAK,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;CAWhE,CAAA"}
package/dist/plugin.js ADDED
@@ -0,0 +1,80 @@
1
+ import { httpPlugin } from '@ultimabase/http';
2
+ import { definePlugin } from 'ultimabase';
3
+ import manifest from '../package.json' with { type: 'json' };
4
+ import { attach, check, deleteHeld, deleteLost, release, resolve, sweep, } from './lifecycle.js';
5
+ import { FilesBlob } from './model.js';
6
+ import { uploads } from './routes.js';
7
+ import { upload } from './upload.js';
8
+ import { runningOf, useRunning } from './values.js';
9
+ /** Ten minutes, between two sweeps. */
10
+ const sweepEvery = 10 * 60 * 1000;
11
+ /** Each model's file fields, with the options `file()` checked. */
12
+ function fieldsOf(models) {
13
+ const fields = new Map();
14
+ for (const model of models) {
15
+ const own = Object.entries(model.fieldTypes).flatMap(([field, type]) => type.plugin === 'files'
16
+ ? [[field, type.options]]
17
+ : []);
18
+ if (own.length > 0)
19
+ fields.set(model.schema.name, new Map(own));
20
+ }
21
+ return fields;
22
+ }
23
+ const signedIn = ({ caller }) => caller.kind === 'user';
24
+ // The sweep's timer, while the application runs.
25
+ let timer;
26
+ /** Sweeps, then again every ten minutes; a failed sweep is logged. */
27
+ async function sweeping() {
28
+ try {
29
+ await sweep();
30
+ }
31
+ catch (error) {
32
+ runningOf().logger.warn({ err: error }, 'The files sweep failed.');
33
+ }
34
+ }
35
+ const definition = definePlugin({
36
+ name: 'files',
37
+ manifest,
38
+ requires: { http: httpPlugin },
39
+ models: [FilesBlob],
40
+ api: ({ plugins }) => {
41
+ plugins.http.mount(uploads(), { bodyLimit: false });
42
+ },
43
+ hooks: {
44
+ beforeCreate: (facts) => resolve(facts),
45
+ beforeUpdate: (facts) => resolve(facts),
46
+ afterCreate: (facts) => attach(facts),
47
+ afterUpdate: (facts) => attach(facts),
48
+ afterDelete: (facts) => release(facts),
49
+ afterUpdateCommit: (facts) => deleteLost(facts),
50
+ afterDeleteCommit: (facts) => deleteHeld(facts),
51
+ },
52
+ start: async ({ options, models, logger }) => {
53
+ const { storage, access } = options;
54
+ storage.start();
55
+ const fields = fieldsOf(models);
56
+ useRunning({ storage, upload: access?.upload ?? signedIn, fields, logger });
57
+ await sweeping();
58
+ timer = setInterval(() => void sweeping(), sweepEvery);
59
+ timer.unref();
60
+ },
61
+ stop: () => {
62
+ clearInterval(timer);
63
+ timer = undefined;
64
+ useRunning(undefined);
65
+ },
66
+ });
67
+ /** Files for the application's file fields; it requires `httpPlugin()`. */
68
+ export const filesPlugin = definition.plugin;
69
+ /** The running files plugin, from server code (D-152). */
70
+ export const files = {
71
+ upload: (body, args) => {
72
+ const given = body instanceof Uint8Array ? body : body.stream();
73
+ const type = body instanceof Uint8Array ? undefined : body.type;
74
+ return upload(given, body instanceof Uint8Array ? body.length : body.size, {
75
+ ...args,
76
+ type,
77
+ });
78
+ },
79
+ check: (options) => check(options),
80
+ };
@@ -0,0 +1,4 @@
1
+ import type { Routes } from '@ultimabase/http';
2
+ /** The upload route, which the plugin mounts without the body limit. */
3
+ export declare const uploads: () => Routes;
4
+ //# sourceMappingURL=routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAW9C,wEAAwE;AACxE,eAAO,MAAM,OAAO,QAAO,MA2BxB,CAAA"}
package/dist/routes.js ADDED
@@ -0,0 +1,32 @@
1
+ import { routes } from '@ultimabase/http';
2
+ import { PublicError } from 'ultimabase';
3
+ import { upload } from './upload.js';
4
+ /** An upload's own refusals, which the HTTP plugin does not know. */
5
+ const statuses = new Map([
6
+ ['PAYLOAD_TOO_LARGE', 413],
7
+ ['UNSUPPORTED_MEDIA_TYPE', 415],
8
+ ]);
9
+ /** The upload route, which the plugin mounts without the body limit. */
10
+ export const uploads = () => routes().post('/files', async (c) => {
11
+ const length = c.req.header('content-length');
12
+ if (length === undefined) {
13
+ return c.json({ code: 'LENGTH_REQUIRED', message: 'Content-Length is required.' }, 411);
14
+ }
15
+ try {
16
+ const { id } = await upload(c.req.raw.body ?? new Uint8Array(), Number(length), {
17
+ for: c.req.query('for') ?? '',
18
+ name: c.req.query('name') ?? '',
19
+ type: c.req.header('content-type'),
20
+ as: c.var.caller,
21
+ });
22
+ return c.json({ id }, 201);
23
+ }
24
+ catch (error) {
25
+ if (!(error instanceof PublicError))
26
+ throw error;
27
+ const status = statuses.get(error.code);
28
+ if (status === undefined)
29
+ throw error;
30
+ return c.json({ code: error.code, message: error.message }, status);
31
+ }
32
+ });
package/dist/s3.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { FileStorage } from './storage.ts';
2
+ /** What `s3()` takes; the first five are required, read when the application starts. */
3
+ export interface S3Options {
4
+ readonly bucket: string | undefined;
5
+ readonly region: string | undefined;
6
+ /** `https://fra1.digitaloceanspaces.com`. */
7
+ readonly endpoint: string | undefined;
8
+ readonly accessKeyId: string | undefined;
9
+ readonly secretAccessKey: string | undefined;
10
+ /** Sends `x-amz-acl: public-read` with a public file, which Spaces needs. */
11
+ readonly publicAcl?: boolean;
12
+ /** The base of a public file's URL, a CDN; the storage's own otherwise. */
13
+ readonly publicUrl?: string;
14
+ }
15
+ /** An S3-compatible storage; its options are checked when the application starts. */
16
+ export declare function s3(options: S3Options): FileStorage;
17
+ //# sourceMappingURL=s3.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"s3.d.ts","sourceRoot":"","sources":["../src/s3.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAG/C,wFAAwF;AACxF,MAAM,WAAW,SAAS;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;IACnC,6CAA6C;IAC7C,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAA;IACrC,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAA;IACxC,QAAQ,CAAC,eAAe,EAAE,MAAM,GAAG,SAAS,CAAA;IAC5C,6EAA6E;IAC7E,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC3B;AA+DD,qFAAqF;AACrF,wBAAgB,EAAE,CAAC,OAAO,EAAE,SAAS,GAAG,WAAW,CAyDlD"}
package/dist/s3.js ADDED
@@ -0,0 +1,99 @@
1
+ import { presign } from './sigv4.js';
2
+ import { isPublic } from './storage.js';
3
+ const required = [
4
+ 'bucket',
5
+ 'region',
6
+ 'endpoint',
7
+ 'accessKeyId',
8
+ 'secretAccessKey',
9
+ ];
10
+ const trimmed = (url) => url.replace(/\/+$/, '');
11
+ /** The options checked: a missing one stops startup, naming it. */
12
+ function configOf(options) {
13
+ const missing = required.filter((key) => !options[key]);
14
+ if (missing.length > 0) {
15
+ throw new Error(`s3() needs ${missing.join(', ')}: undefined or empty.`);
16
+ }
17
+ const { bucket = '', region = '', endpoint = '' } = options;
18
+ const { accessKeyId = '', secretAccessKey = '' } = options;
19
+ const base = `${trimmed(endpoint)}/${encodeURIComponent(bucket)}`;
20
+ return { bucket, region, accessKeyId, secretAccessKey, base };
21
+ }
22
+ /** Sent unchanged when the storage answers otherwise. */
23
+ async function answered(response, what, expected = [200]) {
24
+ if (expected.includes(response.status))
25
+ return response;
26
+ const text = await response.text();
27
+ throw new Error(`s3: ${what} failed with ${response.status}: ${text}`);
28
+ }
29
+ const entities = {
30
+ amp: '&',
31
+ lt: '<',
32
+ gt: '>',
33
+ quot: '"',
34
+ apos: "'",
35
+ };
36
+ const decoded = (text) => text.replaceAll(/&(amp|lt|gt|quot|apos);/g, (entity, name) => entities[name] ?? entity);
37
+ /** The keys of one `ListObjectsV2` page, and the token of the next. */
38
+ function pageOf(xml) {
39
+ const keys = [...xml.matchAll(/<Key>([^<]*)<\/Key>/g)].map(([, key = '']) => decoded(key));
40
+ const truncated = xml.includes('<IsTruncated>true</IsTruncated>');
41
+ const token = /<NextContinuationToken>([^<]*)</.exec(xml)?.[1];
42
+ return { keys, next: truncated && token ? decoded(token) : undefined };
43
+ }
44
+ /** An S3-compatible storage; its options are checked when the application starts. */
45
+ export function s3(options) {
46
+ let config;
47
+ const configured = () => (config ??= configOf(options));
48
+ const objectUrl = (key) => new URL(`${configured().base}/${key}`);
49
+ const signed = (method, url, expires, headers = {}) => presign(configured(), { method, url, expires, headers });
50
+ return {
51
+ start: () => {
52
+ configured();
53
+ },
54
+ put: async (key, body, { type, size, attachment }) => {
55
+ const headers = { 'content-type': type };
56
+ if (attachment)
57
+ headers['content-disposition'] = 'attachment';
58
+ if (isPublic(key) && options.publicAcl === true) {
59
+ headers['x-amz-acl'] = 'public-read';
60
+ }
61
+ const url = signed('PUT', objectUrl(key), 900, headers);
62
+ const response = await fetch(url, {
63
+ method: 'PUT',
64
+ headers: { ...headers, 'content-length': String(size) },
65
+ body,
66
+ duplex: 'half',
67
+ });
68
+ await answered(response, `PUT ${key}`);
69
+ },
70
+ delete: async (key) => {
71
+ const url = signed('DELETE', objectUrl(key), 900);
72
+ const response = await fetch(url, { method: 'DELETE' });
73
+ await answered(response, `DELETE ${key}`, [200, 204, 404]);
74
+ },
75
+ keys: async () => {
76
+ const keys = [];
77
+ let next;
78
+ do {
79
+ const list = new URL(configured().base);
80
+ list.searchParams.set('list-type', '2');
81
+ if (next !== undefined)
82
+ list.searchParams.set('continuation-token', next);
83
+ const response = await fetch(signed('GET', list, 900));
84
+ const page = pageOf(await (await answered(response, 'list')).text());
85
+ keys.push(...page.keys);
86
+ next = page.next;
87
+ } while (next !== undefined);
88
+ return keys;
89
+ },
90
+ url: (key) => {
91
+ if (!isPublic(key))
92
+ return signed('GET', objectUrl(key), 3600);
93
+ const { publicUrl } = options;
94
+ return publicUrl === undefined
95
+ ? objectUrl(key).href
96
+ : `${trimmed(publicUrl)}/${key}`;
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,17 @@
1
+ import type { StandardSchema } from './standard.ts';
2
+ import type { FileOptions } from './field.ts';
3
+ type Node = {
4
+ readonly [key: string]: unknown;
5
+ };
6
+ type Exported = {
7
+ readonly jsonSchema: {
8
+ readonly input: () => Node;
9
+ readonly output: () => Node;
10
+ };
11
+ };
12
+ /** What a field with `options` gives clients, as a Standard Schema. */
13
+ export declare const shapeOf: (options: FileOptions) => StandardSchema & {
14
+ readonly '~standard': Exported;
15
+ };
16
+ export {};
17
+ //# sourceMappingURL=shape.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shape.d.ts","sourceRoot":"","sources":["../src/shape.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AACnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAE7C,KAAK,IAAI,GAAG;IAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,CAAA;AAC/C,KAAK,QAAQ,GAAG;IACf,QAAQ,CAAC,UAAU,EAAE;QACpB,QAAQ,CAAC,KAAK,EAAE,MAAM,IAAI,CAAA;QAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,IAAI,CAAA;KAC3B,CAAA;CACD,CAAA;AAyDD,uEAAuE;AACvE,eAAO,MAAM,OAAO,YACV,WAAW,KAClB,cAAc,GAAG;IAAE,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAA;CAUlD,CAAA"}
package/dist/shape.js ADDED
@@ -0,0 +1,59 @@
1
+ const isNode = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
2
+ const string = { type: 'string' };
3
+ const size = { type: 'integer', minimum: 0 };
4
+ const nullable = (node) => Object.keys(node).length === 0 ? node : { anyOf: [node, { type: 'null' }] };
5
+ const closed = (properties) => ({
6
+ type: 'object',
7
+ properties,
8
+ required: Object.keys(properties),
9
+ additionalProperties: false,
10
+ });
11
+ /** An inline value's JSON Schema, from its schema's; any JSON without one. */
12
+ function inlineOf(inline) {
13
+ if (!isNode(inline) || !isNode(inline['schema']))
14
+ return {};
15
+ const standard = inline['schema']['~standard'];
16
+ const exporter = isNode(standard) ? standard['jsonSchema'] : undefined;
17
+ const output = isNode(exporter) ? exporter['output'] : undefined;
18
+ if (typeof output !== 'function')
19
+ return {};
20
+ const node = Reflect.apply(output, exporter, [
21
+ { target: 'draft-2020-12' },
22
+ ]);
23
+ if (!isNode(node))
24
+ return {};
25
+ const { $schema: _, ...rest } = node;
26
+ return rest;
27
+ }
28
+ function fileNode(options) {
29
+ const inline = Object.entries(options.inline ?? {}).map(([key, given]) => [key, nullable(inlineOf(given))]);
30
+ const variant = closed({ type: string, size, url: string });
31
+ const variants = Object.keys(options.variants ?? {}).map((key) => [key, nullable(variant)]);
32
+ return closed({
33
+ id: string,
34
+ name: string,
35
+ type: string,
36
+ size,
37
+ url: string,
38
+ inline: closed(Object.fromEntries(inline)),
39
+ variants: closed(Object.fromEntries(variants)),
40
+ });
41
+ }
42
+ function nodeOf(options) {
43
+ const node = fileNode(options);
44
+ if (options.list === true)
45
+ return { type: 'array', items: node };
46
+ return options.optional === true ? nullable(node) : node;
47
+ }
48
+ /** What a field with `options` gives clients, as a Standard Schema. */
49
+ export const shapeOf = (options) => ({
50
+ '~standard': {
51
+ version: 1,
52
+ vendor: 'ultimabase-files',
53
+ validate: (value) => ({ value }),
54
+ jsonSchema: {
55
+ input: () => nodeOf(options),
56
+ output: () => nodeOf(options),
57
+ },
58
+ },
59
+ });
@@ -0,0 +1,18 @@
1
+ export interface Signer {
2
+ readonly region: string;
3
+ readonly accessKeyId: string;
4
+ readonly secretAccessKey: string;
5
+ }
6
+ export interface Presigned {
7
+ readonly method: string;
8
+ readonly url: URL;
9
+ /** The headers the request sends and signs, `host` added. */
10
+ readonly headers: {
11
+ readonly [name: string]: string;
12
+ };
13
+ /** In seconds. */
14
+ readonly expires: number;
15
+ }
16
+ /** The URL that runs the request until it expires, signed now. */
17
+ export declare function presign(signer: Signer, request: Presigned): string;
18
+ //# sourceMappingURL=sigv4.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sigv4.d.ts","sourceRoot":"","sources":["../src/sigv4.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,MAAM;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;CAChC;AAED,MAAM,WAAW,SAAS;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAA;IACjB,6DAA6D;IAC7D,QAAQ,CAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAA;IACrD,kBAAkB;IAClB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACxB;AAmBD,kEAAkE;AAClE,wBAAgB,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,GAAG,MAAM,CA0ClE"}
package/dist/sigv4.js ADDED
@@ -0,0 +1,49 @@
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
+ /** RFC 3986, as SigV4 encodes: everything but `A-Za-z0-9-_.~`. */
6
+ const encoded = (value) => encodeURIComponent(value).replaceAll(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
7
+ const hmac = (key, value) => createHmac('sha256', key).update(value).digest();
8
+ /** `20130524T000000Z`. */
9
+ const stampOf = (now) => now
10
+ .toISOString()
11
+ .replaceAll(/[-:]/g, '')
12
+ .replace(/\.\d{3}/, '');
13
+ /** The URL that runs the request until it expires, signed now. */
14
+ export function presign(signer, request) {
15
+ const { method, url, expires } = request;
16
+ const stamp = stampOf(new Date());
17
+ const day = stamp.slice(0, 8);
18
+ const scope = `${day}/${signer.region}/s3/aws4_request`;
19
+ const headers = new Map(Object.entries({ ...request.headers, host: url.host }).map(([name, value]) => [name.toLowerCase(), value.trim()]));
20
+ const names = [...headers.keys()].toSorted();
21
+ const signed = names.join(';');
22
+ const query = new URLSearchParams(url.search);
23
+ query.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
24
+ query.set('X-Amz-Credential', `${signer.accessKeyId}/${scope}`);
25
+ query.set('X-Amz-Date', stamp);
26
+ query.set('X-Amz-Expires', String(expires));
27
+ query.set('X-Amz-SignedHeaders', signed);
28
+ const canonicalQuery = [...query]
29
+ .map(([key, value]) => [encoded(key), encoded(value)])
30
+ .toSorted(([a = ''], [b = '']) => (a < b ? -1 : a > b ? 1 : 0))
31
+ .map(([key, value]) => `${key}=${value}`)
32
+ .join('&');
33
+ const path = url.pathname
34
+ .split('/')
35
+ .map((part) => encoded(decodeURIComponent(part)));
36
+ const canonical = [
37
+ method,
38
+ path.join('/'),
39
+ canonicalQuery,
40
+ names.map((name) => `${name}:${headers.get(name)}\n`).join(''),
41
+ signed,
42
+ 'UNSIGNED-PAYLOAD',
43
+ ].join('\n');
44
+ const digest = createHash('sha256').update(canonical).digest('hex');
45
+ const toSign = ['AWS4-HMAC-SHA256', stamp, scope, digest].join('\n');
46
+ const key = ['s3', 'aws4_request'].reduce(hmac, hmac(hmac(`AWS4${signer.secretAccessKey}`, day), signer.region));
47
+ const signature = createHmac('sha256', key).update(toSign).digest('hex');
48
+ return `${url.origin}${path.join('/')}?${canonicalQuery}&X-Amz-Signature=${signature}`;
49
+ }
@@ -0,0 +1,21 @@
1
+ export interface StandardSchema<I = unknown, O = I> {
2
+ readonly '~standard': {
3
+ readonly version: 1;
4
+ readonly vendor: string;
5
+ readonly validate: (value: unknown) => SchemaResult<O> | Promise<SchemaResult<O>>;
6
+ readonly types?: {
7
+ readonly input: I;
8
+ readonly output: O;
9
+ } | undefined;
10
+ };
11
+ }
12
+ type SchemaResult<O> = {
13
+ readonly value: O;
14
+ readonly issues?: undefined;
15
+ } | {
16
+ readonly issues: readonly {
17
+ readonly message: string;
18
+ }[];
19
+ };
20
+ export {};
21
+ //# sourceMappingURL=standard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"standard.d.ts","sourceRoot":"","sources":["../src/standard.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,cAAc,CAAC,CAAC,GAAG,OAAO,EAAE,CAAC,GAAG,CAAC;IACjD,QAAQ,CAAC,WAAW,EAAE;QACrB,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAA;QACnB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;QACvB,QAAQ,CAAC,QAAQ,EAAE,CAClB,KAAK,EAAE,OAAO,KACV,YAAY,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,CAAA;QAC/C,QAAQ,CAAC,KAAK,CAAC,EAAE;YAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;YAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAA;SAAE,GAAG,SAAS,CAAA;KACtE,CAAA;CACD;AACD,KAAK,YAAY,CAAC,CAAC,IAChB;IAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;IAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAA;CAAE,GAClD;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAAE,CAAA"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ /** A file's bytes: streamed from a request, or held in memory. */
2
+ export type FileBody = ReadableStream<Uint8Array> | Uint8Array;
3
+ /** How an object is stored and served. */
4
+ export interface PutOptions {
5
+ readonly type: string;
6
+ /** The body's length in bytes, which the storage checks. */
7
+ readonly size: number;
8
+ /** Served as a download, so a browser never runs it: SVG, undetected types. */
9
+ readonly attachment: boolean;
10
+ }
11
+ /** A storage adapter: `s3()` or `memoryFiles()`. */
12
+ export interface FileStorage {
13
+ /** Checks its options when the application starts. */
14
+ start(): void;
15
+ put(key: string, body: FileBody, options: PutOptions): Promise<void>;
16
+ /** Deletes an object; a missing one is not an error. */
17
+ delete(key: string): Promise<void>;
18
+ /** Every key it stores. */
19
+ keys(): Promise<readonly string[]>;
20
+ /** A public object's stable URL, a private one's presigned for one hour. */
21
+ url(key: string): string;
22
+ }
23
+ /** Whether a key is served to everyone. */
24
+ export declare const isPublic: (key: string) => boolean;
25
+ /**
26
+ * Each promise ended, then the first failure thrown: a put still running
27
+ * would store an object after its cleanup.
28
+ */
29
+ export declare function settled<T>(promises: readonly Promise<T>[]): Promise<T[]>;
30
+ //# sourceMappingURL=storage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AAIA,kEAAkE;AAClE,MAAM,MAAM,QAAQ,GAAG,cAAc,CAAC,UAAU,CAAC,GAAG,UAAU,CAAA;AAE9D,0CAA0C;AAC1C,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,+EAA+E;IAC/E,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAA;CAC5B;AAED,oDAAoD;AACpD,MAAM,WAAW,WAAW;IAC3B,sDAAsD;IACtD,KAAK,IAAI,IAAI,CAAA;IACb,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACpE,wDAAwD;IACxD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAClC,2BAA2B;IAC3B,IAAI,IAAI,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAAA;IAClC,4EAA4E;IAC5E,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;CACxB;AAED,2CAA2C;AAC3C,eAAO,MAAM,QAAQ,QAAS,MAAM,KAAG,OAAoC,CAAA;AAE3E;;;GAGG;AACH,wBAAsB,OAAO,CAAC,CAAC,EAC9B,QAAQ,EAAE,SAAS,OAAO,CAAC,CAAC,CAAC,EAAE,GAC7B,OAAO,CAAC,CAAC,EAAE,CAAC,CAOd"}
@@ -0,0 +1,16 @@
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
+ /** Whether a key is served to everyone. */
5
+ export const isPublic = (key) => key.startsWith('public/');
6
+ /**
7
+ * Each promise ended, then the first failure thrown: a put still running
8
+ * would store an object after its cleanup.
9
+ */
10
+ export async function settled(promises) {
11
+ const results = await Promise.allSettled(promises);
12
+ const failed = results.find((result) => result.status === 'rejected');
13
+ if (failed !== undefined)
14
+ throw failed.reason;
15
+ return results.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []);
16
+ }
@@ -0,0 +1,19 @@
1
+ import type { Identity } from 'ultimabase';
2
+ import type { FileBody } from './storage.ts';
3
+ /** What an upload says of its file. */
4
+ export interface UploadOptions {
5
+ /** The field the file is for: `Post.cover`. */
6
+ readonly for: string;
7
+ readonly name: string;
8
+ /** The declared `Content-Type`, read only when the bytes say none. */
9
+ readonly type?: string | undefined;
10
+ readonly as?: Identity | undefined;
11
+ }
12
+ /**
13
+ * Stores a file for `options.for`, as `POST /files` does, and gives its id,
14
+ * which a write of that field takes. `size` is the body's length in bytes.
15
+ */
16
+ export declare function upload(body: FileBody, size: number, options: UploadOptions): Promise<{
17
+ readonly id: string;
18
+ }>;
19
+ //# sourceMappingURL=upload.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAa,MAAM,YAAY,CAAA;AAMrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAI5C,uCAAuC;AACvC,MAAM,WAAW,aAAa;IAC7B,+CAA+C;IAC/C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,sEAAsE;IACtE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC,QAAQ,CAAC,EAAE,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAA;CAClC;AAmKD;;;GAGG;AACH,wBAAsB,MAAM,CAC3B,IAAI,EAAE,QAAQ,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,aAAa,GACpB,OAAO,CAAC;IAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC,CAuFlC"}