@ultimabase/files 0.3.0 → 0.4.0-canary.20261102132223

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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # @ultimabase/files
2
2
 
3
+ ## 0.4.0-canary.20261102132223
4
+
5
+ ### Minor Changes
6
+
7
+ - 1d37bc8: `diskFiles({ dir, secret })` stores the files in a directory, for one server with a persistent volume, and `memoryFiles()` now gives real URLs: the application serves both at `GET /files/<key>`, a private file at a signed URL, sandboxed and never sniffed (D-202).
8
+
9
+ ### Patch Changes
10
+
11
+ - 1d7fda8: `ultimabase` exports `QueryOptions`, the `{ as }` every query, model command
12
+ and `fn.run` take; auth, files and password use it instead of their own
13
+ copies.
14
+ - 08e0dec: A private file's URL stays the same for 15 minutes: `s3()` signs it with the start of its 15-minute window and an expiry of 30 minutes, so a row read again, as a live read resends it, keeps its URLs (D-200).
15
+ - Updated dependencies [8dca2cf]
16
+ - Updated dependencies [9b491fb]
17
+ - Updated dependencies [9b491fb]
18
+ - Updated dependencies [1d7fda8]
19
+ - Updated dependencies [a778695]
20
+ - Updated dependencies [e61e1c2]
21
+ - ultimabase@0.4.0-canary.20261102132223
22
+ - @ultimabase/http@0.4.0-canary.20261102132223
23
+
3
24
  ## 0.3.0
4
25
 
5
26
  ### Minor Changes
package/dist/disk.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { FileStorage } from './storage.ts';
2
+ /** What `diskFiles` takes; each option takes `undefined`, which start refuses. */
3
+ export interface DiskOptions {
4
+ /** The directory it stores in, made when missing. */
5
+ readonly dir: string | undefined;
6
+ /** Signs the private files' URLs: 32 characters at least, the same on every start. */
7
+ readonly secret: string | undefined;
8
+ /** The origin its URLs start with: the HTTP server's address by default. */
9
+ readonly servedAt?: string | undefined;
10
+ }
11
+ /** A storage in `dir`, its files served at `GET /files/<key>`. */
12
+ export declare function diskFiles(options: DiskOptions): FileStorage;
13
+ //# sourceMappingURL=disk.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"disk.d.ts","sourceRoot":"","sources":["../src/disk.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAG/C,kFAAkF;AAClF,MAAM,WAAW,WAAW;IAC3B,qDAAqD;IACrD,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;IAChC,sFAAsF;IACtF,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;IACnC,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CACtC;AAgBD,kEAAkE;AAClE,wBAAgB,SAAS,CAAC,OAAO,EAAE,WAAW,GAAG,WAAW,CAmF3D"}
package/dist/disk.js ADDED
@@ -0,0 +1,100 @@
1
+ // `diskFiles({ dir, secret })` (D-202): a storage in a directory, which the
2
+ // application serves; for one server whose directory is a persistent volume.
3
+ import { randomUUID } from 'node:crypto';
4
+ import { createReadStream, createWriteStream, mkdirSync } from 'node:fs';
5
+ import { readdir, readFile, rename, rm, stat, writeFile, } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { Readable } from 'node:stream';
8
+ import { pipeline } from 'node:stream/promises';
9
+ import { serving } from './served.js';
10
+ import { keyPattern } from './storage.js';
11
+ function checked(key) {
12
+ if (!keyPattern.test(key))
13
+ throw new Error(`diskFiles: ${key} is not a file key.`);
14
+ return key;
15
+ }
16
+ const isMeta = (value) => typeof value === 'object' &&
17
+ value !== null &&
18
+ typeof Reflect.get(value, 'type') === 'string' &&
19
+ typeof Reflect.get(value, 'attachment') === 'boolean';
20
+ /** A storage in `dir`, its files served at `GET /files/<key>`. */
21
+ export function diskFiles(options) {
22
+ const { secret } = options;
23
+ const dir = options.dir ?? '';
24
+ const served = serving('diskFiles', options);
25
+ const pathOf = (key) => join(dir, checked(key));
26
+ return {
27
+ start: (server) => {
28
+ if (dir === '')
29
+ throw new Error('diskFiles() needs dir: undefined or empty.');
30
+ if (secret === undefined || secret.length < 32) {
31
+ throw new Error('diskFiles() needs a secret of 32 characters at least.');
32
+ }
33
+ for (const prefix of ['public', 'private']) {
34
+ mkdirSync(join(dir, prefix), { recursive: true });
35
+ }
36
+ served.start(server);
37
+ },
38
+ put: async (key, body, { type, size, attachment }) => {
39
+ const path = pathOf(key);
40
+ const temporary = `${path}.${randomUUID()}.tmp`;
41
+ let total = 0;
42
+ // Counted as it is written: a streamed body stops past its size.
43
+ async function* counted(chunks) {
44
+ for await (const chunk of chunks) {
45
+ total += chunk.length;
46
+ if (total > size)
47
+ throw new Error(`diskFiles: ${key} has over ${size} bytes.`);
48
+ yield chunk;
49
+ }
50
+ }
51
+ try {
52
+ const source = Readable.from(body instanceof Uint8Array ? [body] : body);
53
+ await pipeline(source, counted, createWriteStream(temporary));
54
+ if (total !== size) {
55
+ throw new Error(`diskFiles: ${key} has ${total} bytes, not ${size}.`);
56
+ }
57
+ await writeFile(`${temporary}.json`, JSON.stringify({ type, attachment }));
58
+ // The type first: a body without it is never served.
59
+ await rename(`${temporary}.json`, `${path}.json`);
60
+ await rename(temporary, path);
61
+ }
62
+ finally {
63
+ await rm(temporary, { force: true });
64
+ await rm(`${temporary}.json`, { force: true });
65
+ }
66
+ },
67
+ delete: async (key) => {
68
+ const path = pathOf(key);
69
+ await rm(path, { force: true });
70
+ await rm(`${path}.json`, { force: true });
71
+ },
72
+ keys: async () => {
73
+ const listed = await Promise.all(['public', 'private'].map(async (prefix) => {
74
+ const names = await readdir(join(dir, prefix));
75
+ return names.map((name) => `${prefix}/${name}`);
76
+ }));
77
+ return listed.flat().filter((key) => keyPattern.test(key));
78
+ },
79
+ url: (key) => served.url(checked(key)),
80
+ serve: (key, url) => served.serve(key, url, async () => {
81
+ const path = pathOf(key);
82
+ try {
83
+ const [meta, { size }] = await Promise.all([
84
+ readFile(`${path}.json`, 'utf8'),
85
+ stat(path),
86
+ ]);
87
+ const parsed = JSON.parse(meta);
88
+ if (!isMeta(parsed))
89
+ throw new Error(`diskFiles: ${key}.json is not a file's type.`);
90
+ const body = Readable.toWeb(createReadStream(path));
91
+ return { ...parsed, size, body };
92
+ }
93
+ catch (error) {
94
+ if (error instanceof Error && Reflect.get(error, 'code') === 'ENOENT')
95
+ return undefined;
96
+ throw error;
97
+ }
98
+ }),
99
+ };
100
+ }
package/dist/field.d.ts CHANGED
@@ -31,7 +31,7 @@ export interface FileOptions {
31
31
  readonly maxSize?: number;
32
32
  /** MIME types or patterns: `['image/*', 'application/pdf']`; any by default. */
33
33
  readonly accept?: readonly string[];
34
- /** Served to everyone at a stable URL; presigned for one hour otherwise. */
34
+ /** Served to everyone at a stable URL; presigned otherwise, the same for 15 minutes. */
35
35
  readonly public?: boolean;
36
36
  readonly inline?: {
37
37
  readonly [key: string]: Inline;
@@ -1 +1 @@
1
- {"version":3,"file":"field.d.ts","sourceRoot":"","sources":["../src/field.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAClD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAGnD,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAU1E,6DAA6D;AAC7D,MAAM,WAAW,SAAS;IACzB,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACrB;AAED,uDAAuD;AACvD,MAAM,MAAM,SAAS,GAAG,CAAC,IAAI,EAAE,SAAS,KAAK,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CAAA;AAC3E,kEAAkE;AAClE,MAAM,MAAM,MAAM,GACjB,SAAS,GAAG;IAAE,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAA;CAAE,CAAA;AAEzE,sDAAsD;AACtD,MAAM,MAAM,OAAO,GAAG,CAAC,IAAI,EAAE,SAAS,KAAK,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;AAC7E,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACrB;AAED,mCAAmC;AACnC,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,wCAAwC;IACxC,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAC3B,wCAAwC;IACxC,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;IACvB,kCAAkC;IAClC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB,gFAAgF;IAChF,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE;QAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAA;IACpD,QAAQ,CAAC,QAAQ,CAAC,EAAE;QAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAA;CACvD;AAED,KAAK,QAAQ,CAAC,CAAC,IAAI,CAAC,SAAS;IAC5B,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAA;CACjD,GACE,CAAC,GACD,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,MAAM,CAAC,GACtC,OAAO,CAAC,CAAC,CAAC,GACV,KAAK,CAAA;AACT,KAAK,OAAO,CAAC,CAAC,IAAI,CAAC,SAAS;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GACrD;IAAE,QAAQ,EAAE,CAAC,IAAI,MAAM,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI;CAAE,GAClD;IAAE,QAAQ,EAAE,CAAC,IAAI,KAAK,GAAG,KAAK;CAAE,CAAA;AACnC,KAAK,QAAQ,CAAC,CAAC,IAAI,CAAC,SAAS;IAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAA;CAAE,GACxD;IAAE,QAAQ,EAAE,CAAC,IAAI,MAAM,CAAC,GAAG,WAAW,GAAG,IAAI;CAAE,GAC/C;IAAE,QAAQ,EAAE,CAAC,IAAI,KAAK,GAAG,KAAK;CAAE,CAAA;AACnC,6CAA6C;AAC7C,MAAM,MAAM,MAAM,CAAC,CAAC,IAAI,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAA;AAC1D,KAAK,OAAO,CAAC,CAAC,IAAI,CAAC,SAAS;IAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;CAAE,GAChD,SAAS,MAAM,CAAC,CAAC,CAAC,EAAE,GACpB,MAAM,CAAC,CAAC,CAAC,CAAA;AACZ,KAAK,GAAG,GAAG,MAAM,GAAG,SAAS,MAAM,EAAE,CAAA;AACrC,+EAA+E;AAC/E,KAAK,SAAS,GACX,SAAS,MAAM,EAAE,GACjB;IACA,QAAQ,EAAE,CAAC,IAAI,SAAS,GAAG;QAAE,QAAQ,EAAE,CAAC,IAAI,CAAC,GAAG,GAAG;KAAE,GAAG;QACvD,QAAQ,EAAE,CAAC,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK;KAC7C;CACA,CAAC,SAAS,CAAC,CAAA;AACf,KAAK,OAAO,CAAC,CAAC,IAAI,CAAC,SAAS;IAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;CAAE,GAAG,SAAS,GAAG,MAAM,CAAA;AAExE,mDAAmD;AACnD,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI,KAAK,CAC/B,OAAO,CAAC,CAAC,CAAC,EACV,CAAC,EACD,OAAO,EACP,KAAK,EACL,OAAO,CAAC,CAAC,CAAC,EACV,IAAI,EACJ,KAAK,CAAC,SAAS,EAAE,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,CAAC,CAClD,CAAA;AAED,aAAa;AACb,eAAO,MAAM,cAAc,EAAE,MAAyB,CAAA;AAEtD,gDAAgD;AAChD,eAAO,MAAM,WAAW,SAAU,MAAM,KAAG,OACR,CAAA;AAGnC,eAAO,MAAM,MAAM,YAAa,WAAW,KAAG,IAG5C,CAAA;AAqHF;;;GAGG;AACH,wBAAgB,IAAI,IAAI,SAAS,CAAC,EAAE,CAAC,CAAA;AAGrC,wBAAgB,IAAI,CAAC,KAAK,CAAC,CAAC,SAAS,WAAW,EAC/C,OAAO,EAAE,CAAC,GACR,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAA"}
1
+ {"version":3,"file":"field.d.ts","sourceRoot":"","sources":["../src/field.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  export type { FileField, FileInput, FileOf, FileOptions, Inline, InlineRun, MadeVariant, Variant, } from './field.ts';
2
2
  export type { Checked } from './lifecycle.ts';
3
- export type { MemoryFiles, MemoryObject } from './memory.ts';
3
+ export type { DiskOptions } from './disk.ts';
4
+ export type { MemoryFiles, MemoryObject, MemoryOptions } from './memory.ts';
4
5
  export type { Files, FilesOptions, UploadArgs } from './plugin.ts';
5
6
  export type { S3Options } from './s3.ts';
6
- export type { FileBody, FileStorage, PutOptions } from './storage.ts';
7
+ export type { FileBody, FileStorage, PutOptions, Server } from './storage.ts';
7
8
  export type { FileValue, FileVariant } from './values.ts';
9
+ export { diskFiles } from './disk.ts';
8
10
  export { file } from './field.ts';
9
11
  export { memoryFiles } from './memory.ts';
10
12
  export { files, filesPlugin } from './plugin.ts';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,YAAY,EACX,SAAS,EACT,SAAS,EACT,MAAM,EACN,WAAW,EACX,MAAM,EACN,SAAS,EACT,WAAW,EACX,OAAO,GACP,MAAM,YAAY,CAAA;AACnB,YAAY,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAC7C,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC5D,YAAY,EAAE,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAClE,YAAY,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AACxC,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AACrE,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzD,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAChD,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,YAAY,EACX,SAAS,EACT,SAAS,EACT,MAAM,EACN,WAAW,EACX,MAAM,EACN,SAAS,EACT,WAAW,EACX,OAAO,GACP,MAAM,YAAY,CAAA;AACnB,YAAY,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAC7C,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAC5C,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAC3E,YAAY,EAAE,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAClE,YAAY,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AACxC,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,cAAc,CAAA;AAC7E,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzD,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AACrC,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAChD,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAA"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { diskFiles } from './disk.js';
1
2
  export { file } from './field.js';
2
3
  export { memoryFiles } from './memory.js';
3
4
  export { files, filesPlugin } from './plugin.js';
package/dist/memory.d.ts CHANGED
@@ -11,6 +11,11 @@ export interface MemoryFiles extends FileStorage {
11
11
  }
12
12
  /** The bytes of a body, read to its end. */
13
13
  export declare function bytesOf(body: FileBody): Promise<Uint8Array>;
14
- /** A storage in memory: its URLs are `memory://files/<key>`, never served. */
15
- export declare function memoryFiles(): MemoryFiles;
14
+ /** What `memoryFiles` takes. */
15
+ export interface MemoryOptions {
16
+ /** The origin its URLs start with: the HTTP server's address by default. */
17
+ readonly servedAt?: string | undefined;
18
+ }
19
+ /** A storage in memory, its files served at `GET /files/<key>`. */
20
+ export declare function memoryFiles(options?: MemoryOptions): MemoryFiles;
16
21
  //# sourceMappingURL=memory.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAGzD,uCAAuC;AACvC,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAA;CAC5B;AAED,0DAA0D;AAC1D,MAAM,WAAW,WAAY,SAAQ,WAAW;IAC/C,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,YAAY,CAAC,CAAA;CACnD;AAED,4CAA4C;AAC5C,wBAAsB,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,CAAC,CAGjE;AAED,8EAA8E;AAC9E,wBAAgB,WAAW,IAAI,WAAW,CAqBzC"}
1
+ {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAEzD,uCAAuC;AACvC,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAA;CAC5B;AAED,0DAA0D;AAC1D,MAAM,WAAW,WAAY,SAAQ,WAAW;IAC/C,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,YAAY,CAAC,CAAA;CACnD;AAED,4CAA4C;AAC5C,wBAAsB,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,CAAC,CAGjE;AAED,gCAAgC;AAChC,MAAM,WAAW,aAAa;IAC7B,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CACtC;AAED,mEAAmE;AACnE,wBAAgB,WAAW,CAAC,OAAO,GAAE,aAAkB,GAAG,WAAW,CA0BpE"}
package/dist/memory.js CHANGED
@@ -1,16 +1,19 @@
1
- import { isPublic } from './storage.js';
1
+ // `memoryFiles()` (D-152, D-202): a storage in memory, which tests use, so
2
+ // that no test writes to a real storage; the application serves its files.
3
+ import { serving } from './served.js';
2
4
  /** The bytes of a body, read to its end. */
3
5
  export async function bytesOf(body) {
4
6
  if (body instanceof Uint8Array)
5
7
  return body;
6
8
  return new Uint8Array(await new Response(body).arrayBuffer());
7
9
  }
8
- /** A storage in memory: its URLs are `memory://files/<key>`, never served. */
9
- export function memoryFiles() {
10
+ /** A storage in memory, its files served at `GET /files/<key>`. */
11
+ export function memoryFiles(options = {}) {
10
12
  const objects = new Map();
13
+ const served = serving('memoryFiles', options);
11
14
  return {
12
15
  objects,
13
- start: () => { },
16
+ start: served.start,
14
17
  put: async (key, body, { type, size, attachment }) => {
15
18
  const bytes = await bytesOf(body);
16
19
  if (bytes.length !== size) {
@@ -22,6 +25,10 @@ export function memoryFiles() {
22
25
  objects.delete(key);
23
26
  },
24
27
  keys: async () => [...objects.keys()],
25
- url: (key) => `memory://files/${key}${isPublic(key) ? '' : '?expires=3600'}`,
28
+ url: served.url,
29
+ serve: (key, url) => served.serve(key, url, async () => {
30
+ const object = objects.get(key);
31
+ return object && { ...object, size: object.body.length };
32
+ }),
26
33
  };
27
34
  }
package/dist/plugin.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Http } from '@ultimabase/http';
2
- import type { CallerRule, Identity, PluginOf, PluginTypes } from 'ultimabase';
2
+ import type { CallerRule, PluginOf, PluginTypes, QueryOptions } from 'ultimabase';
3
3
  import type { Checked } from './lifecycle.ts';
4
4
  import type { FileStorage } from './storage.ts';
5
5
  /** What `filesPlugin` takes (D-152). */
@@ -21,11 +21,10 @@ export interface Files extends PluginTypes {
21
21
  /** Files for the application's file fields; it requires `httpPlugin()`. */
22
22
  export declare const filesPlugin: PluginOf<Files>;
23
23
  /** What `files.upload` takes. */
24
- export interface UploadArgs {
24
+ export interface UploadArgs extends QueryOptions {
25
25
  /** The field the file is for: `Post.cover`. */
26
26
  readonly for: string;
27
27
  readonly name: string;
28
- readonly as?: Identity;
29
28
  }
30
29
  /** The running files plugin, from server code (D-152). */
31
30
  export declare const files: {
@@ -1 +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;AAKnB,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,4EAA4E;IAC5E,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB,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;AA6ED,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"}
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,WAAW,EACX,YAAY,EACZ,MAAM,YAAY,CAAA;AAKnB,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,4EAA4E;IAC5E,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB,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;AAgFD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,EAAE,QAAQ,CAAC,KAAK,CAAqB,CAAA;AAE7D,iCAAiC;AACjC,MAAM,WAAW,UAAW,SAAQ,YAAY;IAC/C,+CAA+C;IAC/C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACrB;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 CHANGED
@@ -4,7 +4,7 @@ import manifest from '../package.json' with { type: 'json' };
4
4
  import { defaultMaxSize, isByteCount } from './field.js';
5
5
  import { attach, check, deleteHeld, deleteLost, release, resolve, sweep, } from './lifecycle.js';
6
6
  import { FilesBlob } from './model.js';
7
- import { uploads } from './routes.js';
7
+ import { served, uploads } from './routes.js';
8
8
  import { upload } from './upload.js';
9
9
  import { runningOf, useRunning } from './values.js';
10
10
  /** Ten minutes, between two sweeps. */
@@ -38,8 +38,12 @@ const definition = definePlugin({
38
38
  manifest,
39
39
  requires: { http: httpPlugin },
40
40
  models: [FilesBlob],
41
- api: ({ plugins }) => {
41
+ api: ({ plugins, options }) => {
42
42
  plugins.http.mount(uploads(), { bodyLimit: false });
43
+ // A storage the application serves gets its route (D-202).
44
+ const serve = options.storage.serve?.bind(options.storage);
45
+ if (serve !== undefined)
46
+ plugins.http.mount(served(serve));
43
47
  },
44
48
  hooks: {
45
49
  beforeCreate: (facts) => resolve(facts),
@@ -50,12 +54,12 @@ const definition = definePlugin({
50
54
  afterUpdateCommit: (facts) => deleteLost(facts),
51
55
  afterDeleteCommit: (facts) => deleteHeld(facts),
52
56
  },
53
- start: async ({ options, models, logger }) => {
57
+ start: async ({ options, models, logger, plugins }) => {
54
58
  const { storage, access, maxSize = defaultMaxSize } = options;
55
59
  if (!isByteCount(maxSize)) {
56
60
  throw new Error('filesPlugin’s maxSize is a number of bytes.');
57
61
  }
58
- storage.start();
62
+ storage.start({ url: plugins.http.url() });
59
63
  const fields = fieldsOf(models);
60
64
  useRunning({
61
65
  storage,
package/dist/routes.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { Routes } from '@ultimabase/http';
2
2
  /** The upload route, which the plugin mounts without the body limit. */
3
3
  export declare const uploads: () => Routes;
4
+ /** The route of a storage's files, which `serve` answers. */
5
+ export declare const served: (serve: (key: string, url: URL) => Promise<Response>) => Routes;
4
6
  //# sourceMappingURL=routes.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAY9C,wEAAwE;AACxE,eAAO,MAAM,OAAO,QAAO,MA2BxB,CAAA;AAEH,6DAA6D;AAC7D,eAAO,MAAM,MAAM,UACX,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,QAAQ,CAAC,KACjD,MAUA,CAAA"}
package/dist/routes.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { routes } from '@ultimabase/http';
2
2
  import { PublicError } from 'ultimabase';
3
+ import { keyPattern } from './storage.js';
3
4
  import { upload } from './upload.js';
4
5
  /** An upload's own refusals, which the HTTP plugin does not know. */
5
6
  const statuses = new Map([
@@ -30,3 +31,11 @@ export const uploads = () => routes().post('/files', async (c) => {
30
31
  return c.json({ code: error.code, message: error.message }, status);
31
32
  }
32
33
  });
34
+ /** The route of a storage's files, which `serve` answers. */
35
+ export const served = (serve) => routes().get('/files/:visibility/:id', async (c) => {
36
+ const key = `${c.req.param('visibility')}/${c.req.param('id')}`;
37
+ if (!keyPattern.test(key)) {
38
+ return c.json({ code: 'NOT_FOUND', message: 'No file at this address.' }, 404);
39
+ }
40
+ return serve(key, new URL(c.req.url));
41
+ });
package/dist/s3.d.ts.map CHANGED
@@ -1 +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"}
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,CA8DlD"}
package/dist/s3.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { presign } from './sigv4.js';
2
- import { isPublic } from './storage.js';
2
+ import { isPublic, windowOf } from './storage.js';
3
3
  const required = [
4
4
  'bucket',
5
5
  'region',
@@ -46,7 +46,7 @@ export function s3(options) {
46
46
  let config;
47
47
  const configured = () => (config ??= configOf(options));
48
48
  const objectUrl = (key) => new URL(`${configured().base}/${key}`);
49
- const signed = (method, url, expires, headers = {}) => presign(configured(), { method, url, expires, headers });
49
+ const signed = (method, url, expires, headers = {}, at) => presign(configured(), { method, url, expires, headers }, at);
50
50
  return {
51
51
  start: () => {
52
52
  configured();
@@ -88,8 +88,11 @@ export function s3(options) {
88
88
  return keys;
89
89
  },
90
90
  url: (key) => {
91
- if (!isPublic(key))
92
- return signed('GET', objectUrl(key), 3600);
91
+ if (!isPublic(key)) {
92
+ // The same URL for a whole window, valid one more (D-200).
93
+ const start = new Date(windowOf(Date.now()));
94
+ return signed('GET', objectUrl(key), 1800, {}, start);
95
+ }
93
96
  const { publicUrl } = options;
94
97
  return publicUrl === undefined
95
98
  ? objectUrl(key).href
@@ -0,0 +1,23 @@
1
+ import type { FileBody, Server } from './storage.ts';
2
+ /** An object a storage reads back. */
3
+ export interface StoredObject {
4
+ readonly body: FileBody;
5
+ readonly size: number;
6
+ readonly type: string;
7
+ readonly attachment: boolean;
8
+ }
9
+ /** Where a served storage's files are, and how a private one is signed. */
10
+ export interface Serving {
11
+ readonly start: (server: Server) => void;
12
+ readonly url: (key: string) => string;
13
+ readonly serve: (key: string, url: URL, read: () => Promise<StoredObject | undefined>) => Promise<Response>;
14
+ }
15
+ /**
16
+ * A storage's URLs, at `servedAt` or the HTTP server's address; `secret`
17
+ * signs the private ones, drawn at random when none is given.
18
+ */
19
+ export declare function serving(name: string, options: {
20
+ readonly servedAt?: string | undefined;
21
+ readonly secret?: string | undefined;
22
+ }): Serving;
23
+ //# sourceMappingURL=served.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"served.d.ts","sourceRoot":"","sources":["../src/served.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,cAAc,CAAA;AAGpD,sCAAsC;AACtC,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAA;CAC5B;AAED,2EAA2E;AAC3E,MAAM,WAAW,OAAO;IACvB,QAAQ,CAAC,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IACxC,QAAQ,CAAC,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAA;IACrC,QAAQ,CAAC,KAAK,EAAE,CACf,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,GAAG,EACR,IAAI,EAAE,MAAM,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,KACzC,OAAO,CAAC,QAAQ,CAAC,CAAA;CACtB;AAKD;;;GAGG;AACH,wBAAgB,OAAO,CACtB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE;IACR,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CACpC,GACC,OAAO,CA6DT"}
package/dist/served.js ADDED
@@ -0,0 +1,62 @@
1
+ // The files a storage serves itself (D-202): their URLs on the
2
+ // application's origin, a private one signed for D-200's window, and the
3
+ // answer of `GET /files/<key>`.
4
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
5
+ import { isPublic, urlWindow, windowOf } from './storage.js';
6
+ const answer = (status, code, message) => Response.json({ code, message }, { status });
7
+ /**
8
+ * A storage's URLs, at `servedAt` or the HTTP server's address; `secret`
9
+ * signs the private ones, drawn at random when none is given.
10
+ */
11
+ export function serving(name, options) {
12
+ const secret = options.secret ?? randomBytes(32).toString('base64url');
13
+ let origin;
14
+ const signature = (key, expires) => createHmac('sha256', secret).update(`${key}\n${expires}`).digest();
15
+ const signed = (key, url) => {
16
+ const expires = Number(url.searchParams.get('expires'));
17
+ const given = Buffer.from(url.searchParams.get('signature') ?? '', 'base64url');
18
+ const expected = signature(key, expires);
19
+ return (expires * 1000 > Date.now() &&
20
+ given.length === expected.length &&
21
+ timingSafeEqual(given, expected));
22
+ };
23
+ return {
24
+ start: (server) => {
25
+ origin = (options.servedAt ?? server.url).replace(/\/+$/u, '');
26
+ },
27
+ url: (key) => {
28
+ if (origin === undefined) {
29
+ throw new Error(`${name} gives a file's URL once the application starts.`);
30
+ }
31
+ if (isPublic(key))
32
+ return `${origin}/files/${key}`;
33
+ // The same URL for a whole window, valid one more (D-200).
34
+ const expires = (windowOf(Date.now()) + 2 * urlWindow) / 1000;
35
+ const sig = signature(key, expires).toString('base64url');
36
+ return `${origin}/files/${key}?expires=${expires}&signature=${sig}`;
37
+ },
38
+ serve: async (key, url, read) => {
39
+ if (!isPublic(key) && !signed(key, url)) {
40
+ return answer(403, 'FORBIDDEN', 'This file URL is not signed, or has expired.');
41
+ }
42
+ const object = await read();
43
+ if (object === undefined) {
44
+ return answer(404, 'NOT_FOUND', 'No file at this address.');
45
+ }
46
+ const expires = Number(url.searchParams.get('expires'));
47
+ const headers = new Headers({
48
+ 'Content-Type': object.type,
49
+ 'Content-Length': String(object.size),
50
+ 'X-Content-Type-Options': 'nosniff',
51
+ // An origin of its own, even when it is opened (D-202).
52
+ 'Content-Security-Policy': 'sandbox',
53
+ 'Cache-Control': isPublic(key)
54
+ ? 'public, max-age=31536000, immutable'
55
+ : `private, max-age=${Math.max(0, Math.floor(expires - Date.now() / 1000))}`,
56
+ });
57
+ if (object.attachment)
58
+ headers.set('Content-Disposition', 'attachment');
59
+ return new Response(object.body, { headers });
60
+ },
61
+ };
62
+ }
package/dist/sigv4.d.ts CHANGED
@@ -13,6 +13,6 @@ export interface Presigned {
13
13
  /** In seconds. */
14
14
  readonly expires: number;
15
15
  }
16
- /** The URL that runs the request until it expires, signed now. */
17
- export declare function presign(signer: Signer, request: Presigned): string;
16
+ /** The URL that runs the request until `expires` seconds after `at`. */
17
+ export declare function presign(signer: Signer, request: Presigned, at?: Date): string;
18
18
  //# sourceMappingURL=sigv4.d.ts.map
@@ -1 +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"}
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,wEAAwE;AACxE,wBAAgB,OAAO,CACtB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,SAAS,EAClB,EAAE,GAAE,IAAiB,GACnB,MAAM,CA0CR"}
package/dist/sigv4.js CHANGED
@@ -10,10 +10,10 @@ const stampOf = (now) => now
10
10
  .toISOString()
11
11
  .replaceAll(/[-:]/g, '')
12
12
  .replace(/\.\d{3}/, '');
13
- /** The URL that runs the request until it expires, signed now. */
14
- export function presign(signer, request) {
13
+ /** The URL that runs the request until `expires` seconds after `at`. */
14
+ export function presign(signer, request, at = new Date()) {
15
15
  const { method, url, expires } = request;
16
- const stamp = stampOf(new Date());
16
+ const stamp = stampOf(at);
17
17
  const day = stamp.slice(0, 8);
18
18
  const scope = `${day}/${signer.region}/s3/aws4_request`;
19
19
  const headers = new Map(Object.entries({ ...request.headers, host: url.host }).map(([name, value]) => [name.toLowerCase(), value.trim()]));
package/dist/storage.d.ts CHANGED
@@ -8,18 +8,34 @@ export interface PutOptions {
8
8
  /** Served as a download, so a browser never runs it: SVG, undetected types. */
9
9
  readonly attachment: boolean;
10
10
  }
11
- /** A storage adapter: `s3()` or `memoryFiles()`. */
11
+ /** The running HTTP server, which a storage that serves its files reads. */
12
+ export interface Server {
13
+ /** Its address, as `http://localhost:3000`. */
14
+ readonly url: string;
15
+ }
16
+ /** A storage adapter: `s3()`, `diskFiles()` or `memoryFiles()`. */
12
17
  export interface FileStorage {
13
18
  /** Checks its options when the application starts. */
14
- start(): void;
19
+ start(server: Server): void;
15
20
  put(key: string, body: FileBody, options: PutOptions): Promise<void>;
16
21
  /** Deletes an object; a missing one is not an error. */
17
22
  delete(key: string): Promise<void>;
18
23
  /** Every key it stores. */
19
24
  keys(): Promise<readonly string[]>;
20
- /** A public object's stable URL, a private one's presigned for one hour. */
25
+ /** A public object's stable URL, a private one's presigned, the same for 15 minutes (D-200). */
21
26
  url(key: string): string;
27
+ /**
28
+ * The answer of `GET /files/<key>`, for a storage the application serves
29
+ * (D-202); `url` then gives that route's URL.
30
+ */
31
+ serve?(key: string, url: URL): Promise<Response>;
22
32
  }
33
+ /** A private file's URL stays the same this long, in milliseconds (D-200). */
34
+ export declare const urlWindow: number;
35
+ /** The start of the window `now` is in, in milliseconds. */
36
+ export declare const windowOf: (now: number) => number;
37
+ /** A file's key: `public/<uuid>` or `private/<uuid>`. */
38
+ export declare const keyPattern: RegExp;
23
39
  /** Whether a key is served to everyone. */
24
40
  export declare const isPublic: (key: string) => boolean;
25
41
  /**
@@ -1 +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"}
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,4EAA4E;AAC5E,MAAM,WAAW,MAAM;IACtB,+CAA+C;IAC/C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CACpB;AAED,mEAAmE;AACnE,MAAM,WAAW,WAAW;IAC3B,sDAAsD;IACtD,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,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,gGAAgG;IAChG,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;IACxB;;;OAGG;IACH,KAAK,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAChD;AAED,8EAA8E;AAC9E,eAAO,MAAM,SAAS,EAAE,MAAuB,CAAA;AAE/C,4DAA4D;AAC5D,eAAO,MAAM,QAAQ,QAAS,MAAM,KAAG,MACC,CAAA;AAExC,yDAAyD;AACzD,eAAO,MAAM,UAAU,EAAE,MAA6C,CAAA;AAEtE,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"}
package/dist/storage.js CHANGED
@@ -1,6 +1,12 @@
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.
1
+ // What `filesPlugin` stores files in (D-152, D-202): an S3-compatible
2
+ // storage, a disk, or memory for tests. A key starts with `public/` or
3
+ // `private/`, which says how the object is served.
4
+ /** A private file's URL stays the same this long, in milliseconds (D-200). */
5
+ export const urlWindow = 15 * 60 * 1000;
6
+ /** The start of the window `now` is in, in milliseconds. */
7
+ export const windowOf = (now) => Math.floor(now / urlWindow) * urlWindow;
8
+ /** A file's key: `public/<uuid>` or `private/<uuid>`. */
9
+ export const keyPattern = /^(public|private)\/[0-9a-f-]{36}$/u;
4
10
  /** Whether a key is served to everyone. */
5
11
  export const isPublic = (key) => key.startsWith('public/');
6
12
  /**
package/dist/upload.d.ts CHANGED
@@ -1,13 +1,12 @@
1
- import type { Identity } from 'ultimabase';
1
+ import type { QueryOptions } from 'ultimabase';
2
2
  import type { FileBody } from './storage.ts';
3
3
  /** What an upload says of its file. */
4
- export interface UploadOptions {
4
+ export interface UploadOptions extends QueryOptions {
5
5
  /** The field the file is for: `Post.cover`. */
6
6
  readonly for: string;
7
7
  readonly name: string;
8
8
  /** The declared `Content-Type`, read only when the bytes say none. */
9
9
  readonly type?: string | undefined;
10
- readonly as?: Identity | undefined;
11
10
  }
12
11
  /**
13
12
  * Stores a file for `options.for`, as `POST /files` does, and gives its id,
@@ -1 +1 @@
1
- {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAa,MAAM,YAAY,CAAA;AAKrD,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"}
1
+ {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAuB,YAAY,EAAE,MAAM,YAAY,CAAA;AAKnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAI5C,uCAAuC;AACvC,MAAM,WAAW,aAAc,SAAQ,YAAY;IAClD,+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;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"}
package/dist/values.d.ts CHANGED
@@ -17,7 +17,7 @@ export interface FileValue<I extends object = {
17
17
  readonly name: string;
18
18
  readonly type: string;
19
19
  readonly size: number;
20
- /** Stable for a public file, presigned for one hour for a private one. */
20
+ /** Stable for a public file, presigned for a private one, the same for 15 minutes. */
21
21
  readonly url: string;
22
22
  readonly inline: I;
23
23
  readonly variants: V;
@@ -1 +1 @@
1
- {"version":3,"file":"values.d.ts","sourceRoot":"","sources":["../src/values.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"values.d.ts","sourceRoot":"","sources":["../src/values.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimabase/files",
3
- "version": "0.3.0",
3
+ "version": "0.4.0-canary.20261102132223",
4
4
  "description": "Files for UltimaBase: uploads to S3-compatible storage, file fields, variants, and no orphan ever.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -34,15 +34,15 @@
34
34
  "file-type": "22.2.0"
35
35
  },
36
36
  "peerDependencies": {
37
- "@ultimabase/http": "^0.3.0",
38
- "ultimabase": "^0.3.0"
37
+ "@ultimabase/http": "0.4.0-canary.20261102132223",
38
+ "ultimabase": "0.4.0-canary.20261102132223"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/pg": "8.23.1",
42
- "@ultimabase/http": "0.3.0",
43
- "@ultimabase/postgres": "0.3.0",
42
+ "@ultimabase/http": "0.4.0-canary.20261102132223",
43
+ "@ultimabase/postgres": "0.4.0-canary.20261102132223",
44
44
  "pg": "8.23.1",
45
- "ultimabase": "0.3.0",
45
+ "ultimabase": "0.4.0-canary.20261102132223",
46
46
  "vite": "8.3.4",
47
47
  "vitest": "5.0.5"
48
48
  }
package/src/disk.ts ADDED
@@ -0,0 +1,128 @@
1
+ // `diskFiles({ dir, secret })` (D-202): a storage in a directory, which the
2
+ // application serves; for one server whose directory is a persistent volume.
3
+ import { randomUUID } from 'node:crypto'
4
+ import { createReadStream, createWriteStream, mkdirSync } from 'node:fs'
5
+ import {
6
+ readdir,
7
+ readFile,
8
+ rename,
9
+ rm,
10
+ stat,
11
+ writeFile,
12
+ } from 'node:fs/promises'
13
+ import { join } from 'node:path'
14
+ import { Readable } from 'node:stream'
15
+ import { pipeline } from 'node:stream/promises'
16
+ import { serving } from './served.ts'
17
+ import type { FileStorage } from './storage.ts'
18
+ import { keyPattern } from './storage.ts'
19
+
20
+ /** What `diskFiles` takes; each option takes `undefined`, which start refuses. */
21
+ export interface DiskOptions {
22
+ /** The directory it stores in, made when missing. */
23
+ readonly dir: string | undefined
24
+ /** Signs the private files' URLs: 32 characters at least, the same on every start. */
25
+ readonly secret: string | undefined
26
+ /** The origin its URLs start with: the HTTP server's address by default. */
27
+ readonly servedAt?: string | undefined
28
+ }
29
+
30
+ function checked(key: string): string {
31
+ if (!keyPattern.test(key))
32
+ throw new Error(`diskFiles: ${key} is not a file key.`)
33
+ return key
34
+ }
35
+
36
+ const isMeta = (
37
+ value: unknown,
38
+ ): value is { type: string; attachment: boolean } =>
39
+ typeof value === 'object' &&
40
+ value !== null &&
41
+ typeof Reflect.get(value, 'type') === 'string' &&
42
+ typeof Reflect.get(value, 'attachment') === 'boolean'
43
+
44
+ /** A storage in `dir`, its files served at `GET /files/<key>`. */
45
+ export function diskFiles(options: DiskOptions): FileStorage {
46
+ const { secret } = options
47
+ const dir = options.dir ?? ''
48
+ const served = serving('diskFiles', options)
49
+ const pathOf = (key: string) => join(dir, checked(key))
50
+ return {
51
+ start: (server) => {
52
+ if (dir === '')
53
+ throw new Error('diskFiles() needs dir: undefined or empty.')
54
+ if (secret === undefined || secret.length < 32) {
55
+ throw new Error('diskFiles() needs a secret of 32 characters at least.')
56
+ }
57
+ for (const prefix of ['public', 'private']) {
58
+ mkdirSync(join(dir, prefix), { recursive: true })
59
+ }
60
+ served.start(server)
61
+ },
62
+ put: async (key, body, { type, size, attachment }) => {
63
+ const path = pathOf(key)
64
+ const temporary = `${path}.${randomUUID()}.tmp`
65
+ let total = 0
66
+ // Counted as it is written: a streamed body stops past its size.
67
+ async function* counted(chunks: AsyncIterable<Uint8Array>) {
68
+ for await (const chunk of chunks) {
69
+ total += chunk.length
70
+ if (total > size)
71
+ throw new Error(`diskFiles: ${key} has over ${size} bytes.`)
72
+ yield chunk
73
+ }
74
+ }
75
+ try {
76
+ const source = Readable.from(body instanceof Uint8Array ? [body] : body)
77
+ await pipeline(source, counted, createWriteStream(temporary))
78
+ if (total !== size) {
79
+ throw new Error(`diskFiles: ${key} has ${total} bytes, not ${size}.`)
80
+ }
81
+ await writeFile(
82
+ `${temporary}.json`,
83
+ JSON.stringify({ type, attachment }),
84
+ )
85
+ // The type first: a body without it is never served.
86
+ await rename(`${temporary}.json`, `${path}.json`)
87
+ await rename(temporary, path)
88
+ } finally {
89
+ await rm(temporary, { force: true })
90
+ await rm(`${temporary}.json`, { force: true })
91
+ }
92
+ },
93
+ delete: async (key) => {
94
+ const path = pathOf(key)
95
+ await rm(path, { force: true })
96
+ await rm(`${path}.json`, { force: true })
97
+ },
98
+ keys: async () => {
99
+ const listed = await Promise.all(
100
+ ['public', 'private'].map(async (prefix) => {
101
+ const names = await readdir(join(dir, prefix))
102
+ return names.map((name) => `${prefix}/${name}`)
103
+ }),
104
+ )
105
+ return listed.flat().filter((key) => keyPattern.test(key))
106
+ },
107
+ url: (key) => served.url(checked(key)),
108
+ serve: (key, url) =>
109
+ served.serve(key, url, async () => {
110
+ const path = pathOf(key)
111
+ try {
112
+ const [meta, { size }] = await Promise.all([
113
+ readFile(`${path}.json`, 'utf8'),
114
+ stat(path),
115
+ ])
116
+ const parsed: unknown = JSON.parse(meta)
117
+ if (!isMeta(parsed))
118
+ throw new Error(`diskFiles: ${key}.json is not a file's type.`)
119
+ const body = Readable.toWeb(createReadStream(path))
120
+ return { ...parsed, size, body }
121
+ } catch (error) {
122
+ if (error instanceof Error && Reflect.get(error, 'code') === 'ENOENT')
123
+ return undefined
124
+ throw error
125
+ }
126
+ }),
127
+ }
128
+ }
package/src/field.ts CHANGED
@@ -46,7 +46,7 @@ export interface FileOptions {
46
46
  readonly maxSize?: number
47
47
  /** MIME types or patterns: `['image/*', 'application/pdf']`; any by default. */
48
48
  readonly accept?: readonly string[]
49
- /** Served to everyone at a stable URL; presigned for one hour otherwise. */
49
+ /** Served to everyone at a stable URL; presigned otherwise, the same for 15 minutes. */
50
50
  readonly public?: boolean
51
51
  readonly inline?: { readonly [key: string]: Inline }
52
52
  readonly variants?: { readonly [key: string]: Variant }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- // `@ultimabase/files` (D-152): file fields stored in an S3-compatible
2
- // storage, uploaded with `POST /files`, and never an orphan.
1
+ // `@ultimabase/files` (D-152, D-202): file fields stored in an S3-compatible
2
+ // storage, a disk or memory, uploaded with `POST /files`, and never an orphan.
3
3
  export type {
4
4
  FileField,
5
5
  FileInput,
@@ -11,11 +11,13 @@ export type {
11
11
  Variant,
12
12
  } from './field.ts'
13
13
  export type { Checked } from './lifecycle.ts'
14
- export type { MemoryFiles, MemoryObject } from './memory.ts'
14
+ export type { DiskOptions } from './disk.ts'
15
+ export type { MemoryFiles, MemoryObject, MemoryOptions } from './memory.ts'
15
16
  export type { Files, FilesOptions, UploadArgs } from './plugin.ts'
16
17
  export type { S3Options } from './s3.ts'
17
- export type { FileBody, FileStorage, PutOptions } from './storage.ts'
18
+ export type { FileBody, FileStorage, PutOptions, Server } from './storage.ts'
18
19
  export type { FileValue, FileVariant } from './values.ts'
20
+ export { diskFiles } from './disk.ts'
19
21
  export { file } from './field.ts'
20
22
  export { memoryFiles } from './memory.ts'
21
23
  export { files, filesPlugin } from './plugin.ts'
package/src/memory.ts CHANGED
@@ -1,7 +1,7 @@
1
- // `memoryFiles()` (D-152): a storage in memory, which tests use, so that no
2
- // test writes to a real storage.
1
+ // `memoryFiles()` (D-152, D-202): a storage in memory, which tests use, so
2
+ // that no test writes to a real storage; the application serves its files.
3
+ import { serving } from './served.ts'
3
4
  import type { FileBody, FileStorage } from './storage.ts'
4
- import { isPublic } from './storage.ts'
5
5
 
6
6
  /** An object `memoryFiles()` holds. */
7
7
  export interface MemoryObject {
@@ -21,12 +21,19 @@ export async function bytesOf(body: FileBody): Promise<Uint8Array> {
21
21
  return new Uint8Array(await new Response(body).arrayBuffer())
22
22
  }
23
23
 
24
- /** A storage in memory: its URLs are `memory://files/<key>`, never served. */
25
- export function memoryFiles(): MemoryFiles {
24
+ /** What `memoryFiles` takes. */
25
+ export interface MemoryOptions {
26
+ /** The origin its URLs start with: the HTTP server's address by default. */
27
+ readonly servedAt?: string | undefined
28
+ }
29
+
30
+ /** A storage in memory, its files served at `GET /files/<key>`. */
31
+ export function memoryFiles(options: MemoryOptions = {}): MemoryFiles {
26
32
  const objects = new Map<string, MemoryObject>()
33
+ const served = serving('memoryFiles', options)
27
34
  return {
28
35
  objects,
29
- start: () => {},
36
+ start: served.start,
30
37
  put: async (key, body, { type, size, attachment }) => {
31
38
  const bytes = await bytesOf(body)
32
39
  if (bytes.length !== size) {
@@ -40,7 +47,11 @@ export function memoryFiles(): MemoryFiles {
40
47
  objects.delete(key)
41
48
  },
42
49
  keys: async () => [...objects.keys()],
43
- url: (key) =>
44
- `memory://files/${key}${isPublic(key) ? '' : '?expires=3600'}`,
50
+ url: served.url,
51
+ serve: (key, url) =>
52
+ served.serve(key, url, async () => {
53
+ const object = objects.get(key)
54
+ return object && { ...object, size: object.body.length }
55
+ }),
45
56
  }
46
57
  }
package/src/plugin.ts CHANGED
@@ -6,9 +6,9 @@ import { httpPlugin } from '@ultimabase/http'
6
6
  import type {
7
7
  CallerRule,
8
8
  ExposedModel,
9
- Identity,
10
9
  PluginOf,
11
10
  PluginTypes,
11
+ QueryOptions,
12
12
  } from 'ultimabase'
13
13
  import { definePlugin } from 'ultimabase'
14
14
  import manifest from '../package.json' with { type: 'json' }
@@ -26,7 +26,7 @@ import {
26
26
  } from './lifecycle.ts'
27
27
  import { FilesBlob } from './model.ts'
28
28
  import type { FileBody, FileStorage } from './storage.ts'
29
- import { uploads } from './routes.ts'
29
+ import { served, uploads } from './routes.ts'
30
30
  import { upload } from './upload.ts'
31
31
  import { runningOf, useRunning } from './values.ts'
32
32
 
@@ -86,8 +86,11 @@ const definition = definePlugin<Files>({
86
86
  manifest,
87
87
  requires: { http: httpPlugin },
88
88
  models: [FilesBlob],
89
- api: ({ plugins }) => {
89
+ api: ({ plugins, options }) => {
90
90
  plugins.http.mount(uploads(), { bodyLimit: false })
91
+ // A storage the application serves gets its route (D-202).
92
+ const serve = options.storage.serve?.bind(options.storage)
93
+ if (serve !== undefined) plugins.http.mount(served(serve))
91
94
  },
92
95
  hooks: {
93
96
  beforeCreate: (facts) => resolve(facts),
@@ -98,12 +101,12 @@ const definition = definePlugin<Files>({
98
101
  afterUpdateCommit: (facts) => deleteLost(facts),
99
102
  afterDeleteCommit: (facts) => deleteHeld(facts),
100
103
  },
101
- start: async ({ options, models, logger }) => {
104
+ start: async ({ options, models, logger, plugins }) => {
102
105
  const { storage, access, maxSize = defaultMaxSize } = options
103
106
  if (!isByteCount(maxSize)) {
104
107
  throw new Error('filesPlugin’s maxSize is a number of bytes.')
105
108
  }
106
- storage.start()
109
+ storage.start({ url: plugins.http.url() })
107
110
  const fields = fieldsOf(models)
108
111
  useRunning({
109
112
  storage,
@@ -127,11 +130,10 @@ const definition = definePlugin<Files>({
127
130
  export const filesPlugin: PluginOf<Files> = definition.plugin
128
131
 
129
132
  /** What `files.upload` takes. */
130
- export interface UploadArgs {
133
+ export interface UploadArgs extends QueryOptions {
131
134
  /** The field the file is for: `Post.cover`. */
132
135
  readonly for: string
133
136
  readonly name: string
134
- readonly as?: Identity
135
137
  }
136
138
 
137
139
  /** The running files plugin, from server code (D-152). */
package/src/routes.ts CHANGED
@@ -1,8 +1,10 @@
1
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).
2
+ // body, which its field's `maxSize` limits, not the HTTP plugin (D-153); and
3
+ // `GET /files/<key>`, for a storage the application serves (D-202).
3
4
  import type { Routes } from '@ultimabase/http'
4
5
  import { routes } from '@ultimabase/http'
5
6
  import { PublicError } from 'ultimabase'
7
+ import { keyPattern } from './storage.ts'
6
8
  import { upload } from './upload.ts'
7
9
 
8
10
  /** An upload's own refusals, which the HTTP plugin does not know. */
@@ -40,3 +42,18 @@ export const uploads = (): Routes =>
40
42
  return c.json({ code: error.code, message: error.message }, status)
41
43
  }
42
44
  })
45
+
46
+ /** The route of a storage's files, which `serve` answers. */
47
+ export const served = (
48
+ serve: (key: string, url: URL) => Promise<Response>,
49
+ ): Routes =>
50
+ routes().get('/files/:visibility/:id', async (c) => {
51
+ const key = `${c.req.param('visibility')}/${c.req.param('id')}`
52
+ if (!keyPattern.test(key)) {
53
+ return c.json(
54
+ { code: 'NOT_FOUND', message: 'No file at this address.' },
55
+ 404,
56
+ )
57
+ }
58
+ return serve(key, new URL(c.req.url))
59
+ })
package/src/s3.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import type { Signer } from './sigv4.ts'
4
4
  import { presign } from './sigv4.ts'
5
5
  import type { FileStorage } from './storage.ts'
6
- import { isPublic } from './storage.ts'
6
+ import { isPublic, windowOf } from './storage.ts'
7
7
 
8
8
  /** What `s3()` takes; the first five are required, read when the application starts. */
9
9
  export interface S3Options {
@@ -90,7 +90,8 @@ export function s3(options: S3Options): FileStorage {
90
90
  url: URL,
91
91
  expires: number,
92
92
  headers: { readonly [name: string]: string } = {},
93
- ) => presign(configured(), { method, url, expires, headers })
93
+ at?: Date,
94
+ ) => presign(configured(), { method, url, expires, headers }, at)
94
95
  return {
95
96
  start: () => {
96
97
  configured()
@@ -131,7 +132,11 @@ export function s3(options: S3Options): FileStorage {
131
132
  return keys
132
133
  },
133
134
  url: (key) => {
134
- if (!isPublic(key)) return signed('GET', objectUrl(key), 3600)
135
+ if (!isPublic(key)) {
136
+ // The same URL for a whole window, valid one more (D-200).
137
+ const start = new Date(windowOf(Date.now()))
138
+ return signed('GET', objectUrl(key), 1800, {}, start)
139
+ }
135
140
  const { publicUrl } = options
136
141
  return publicUrl === undefined
137
142
  ? objectUrl(key).href
package/src/served.ts ADDED
@@ -0,0 +1,101 @@
1
+ // The files a storage serves itself (D-202): their URLs on the
2
+ // application's origin, a private one signed for D-200's window, and the
3
+ // answer of `GET /files/<key>`.
4
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
5
+ import type { FileBody, Server } from './storage.ts'
6
+ import { isPublic, urlWindow, windowOf } from './storage.ts'
7
+
8
+ /** An object a storage reads back. */
9
+ export interface StoredObject {
10
+ readonly body: FileBody
11
+ readonly size: number
12
+ readonly type: string
13
+ readonly attachment: boolean
14
+ }
15
+
16
+ /** Where a served storage's files are, and how a private one is signed. */
17
+ export interface Serving {
18
+ readonly start: (server: Server) => void
19
+ readonly url: (key: string) => string
20
+ readonly serve: (
21
+ key: string,
22
+ url: URL,
23
+ read: () => Promise<StoredObject | undefined>,
24
+ ) => Promise<Response>
25
+ }
26
+
27
+ const answer = (status: 403 | 404, code: string, message: string) =>
28
+ Response.json({ code, message }, { status })
29
+
30
+ /**
31
+ * A storage's URLs, at `servedAt` or the HTTP server's address; `secret`
32
+ * signs the private ones, drawn at random when none is given.
33
+ */
34
+ export function serving(
35
+ name: string,
36
+ options: {
37
+ readonly servedAt?: string | undefined
38
+ readonly secret?: string | undefined
39
+ },
40
+ ): Serving {
41
+ const secret = options.secret ?? randomBytes(32).toString('base64url')
42
+ let origin: string | undefined
43
+ const signature = (key: string, expires: number) =>
44
+ createHmac('sha256', secret).update(`${key}\n${expires}`).digest()
45
+ const signed = (key: string, url: URL) => {
46
+ const expires = Number(url.searchParams.get('expires'))
47
+ const given = Buffer.from(
48
+ url.searchParams.get('signature') ?? '',
49
+ 'base64url',
50
+ )
51
+ const expected = signature(key, expires)
52
+ return (
53
+ expires * 1000 > Date.now() &&
54
+ given.length === expected.length &&
55
+ timingSafeEqual(given, expected)
56
+ )
57
+ }
58
+ return {
59
+ start: (server) => {
60
+ origin = (options.servedAt ?? server.url).replace(/\/+$/u, '')
61
+ },
62
+ url: (key) => {
63
+ if (origin === undefined) {
64
+ throw new Error(
65
+ `${name} gives a file's URL once the application starts.`,
66
+ )
67
+ }
68
+ if (isPublic(key)) return `${origin}/files/${key}`
69
+ // The same URL for a whole window, valid one more (D-200).
70
+ const expires = (windowOf(Date.now()) + 2 * urlWindow) / 1000
71
+ const sig = signature(key, expires).toString('base64url')
72
+ return `${origin}/files/${key}?expires=${expires}&signature=${sig}`
73
+ },
74
+ serve: async (key, url, read) => {
75
+ if (!isPublic(key) && !signed(key, url)) {
76
+ return answer(
77
+ 403,
78
+ 'FORBIDDEN',
79
+ 'This file URL is not signed, or has expired.',
80
+ )
81
+ }
82
+ const object = await read()
83
+ if (object === undefined) {
84
+ return answer(404, 'NOT_FOUND', 'No file at this address.')
85
+ }
86
+ const expires = Number(url.searchParams.get('expires'))
87
+ const headers = new Headers({
88
+ 'Content-Type': object.type,
89
+ 'Content-Length': String(object.size),
90
+ 'X-Content-Type-Options': 'nosniff',
91
+ // An origin of its own, even when it is opened (D-202).
92
+ 'Content-Security-Policy': 'sandbox',
93
+ 'Cache-Control': isPublic(key)
94
+ ? 'public, max-age=31536000, immutable'
95
+ : `private, max-age=${Math.max(0, Math.floor(expires - Date.now() / 1000))}`,
96
+ })
97
+ if (object.attachment) headers.set('Content-Disposition', 'attachment')
98
+ return new Response(object.body, { headers })
99
+ },
100
+ }
101
+ }
package/src/sigv4.ts CHANGED
@@ -35,10 +35,14 @@ const stampOf = (now: Date): string =>
35
35
  .replaceAll(/[-:]/g, '')
36
36
  .replace(/\.\d{3}/, '')
37
37
 
38
- /** The URL that runs the request until it expires, signed now. */
39
- export function presign(signer: Signer, request: Presigned): string {
38
+ /** The URL that runs the request until `expires` seconds after `at`. */
39
+ export function presign(
40
+ signer: Signer,
41
+ request: Presigned,
42
+ at: Date = new Date(),
43
+ ): string {
40
44
  const { method, url, expires } = request
41
- const stamp = stampOf(new Date())
45
+ const stamp = stampOf(at)
42
46
  const day = stamp.slice(0, 8)
43
47
  const scope = `${day}/${signer.region}/s3/aws4_request`
44
48
  const headers = new Map(
package/src/storage.ts CHANGED
@@ -1,6 +1,6 @@
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.
1
+ // What `filesPlugin` stores files in (D-152, D-202): an S3-compatible
2
+ // storage, a disk, or memory for tests. A key starts with `public/` or
3
+ // `private/`, which says how the object is served.
4
4
 
5
5
  /** A file's bytes: streamed from a request, or held in memory. */
6
6
  export type FileBody = ReadableStream<Uint8Array> | Uint8Array
@@ -14,19 +14,40 @@ export interface PutOptions {
14
14
  readonly attachment: boolean
15
15
  }
16
16
 
17
- /** A storage adapter: `s3()` or `memoryFiles()`. */
17
+ /** The running HTTP server, which a storage that serves its files reads. */
18
+ export interface Server {
19
+ /** Its address, as `http://localhost:3000`. */
20
+ readonly url: string
21
+ }
22
+
23
+ /** A storage adapter: `s3()`, `diskFiles()` or `memoryFiles()`. */
18
24
  export interface FileStorage {
19
25
  /** Checks its options when the application starts. */
20
- start(): void
26
+ start(server: Server): void
21
27
  put(key: string, body: FileBody, options: PutOptions): Promise<void>
22
28
  /** Deletes an object; a missing one is not an error. */
23
29
  delete(key: string): Promise<void>
24
30
  /** Every key it stores. */
25
31
  keys(): Promise<readonly string[]>
26
- /** A public object's stable URL, a private one's presigned for one hour. */
32
+ /** A public object's stable URL, a private one's presigned, the same for 15 minutes (D-200). */
27
33
  url(key: string): string
34
+ /**
35
+ * The answer of `GET /files/<key>`, for a storage the application serves
36
+ * (D-202); `url` then gives that route's URL.
37
+ */
38
+ serve?(key: string, url: URL): Promise<Response>
28
39
  }
29
40
 
41
+ /** A private file's URL stays the same this long, in milliseconds (D-200). */
42
+ export const urlWindow: number = 15 * 60 * 1000
43
+
44
+ /** The start of the window `now` is in, in milliseconds. */
45
+ export const windowOf = (now: number): number =>
46
+ Math.floor(now / urlWindow) * urlWindow
47
+
48
+ /** A file's key: `public/<uuid>` or `private/<uuid>`. */
49
+ export const keyPattern: RegExp = /^(public|private)\/[0-9a-f-]{36}$/u
50
+
30
51
  /** Whether a key is served to everyone. */
31
52
  export const isPublic = (key: string): boolean => key.startsWith('public/')
32
53
 
package/src/upload.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // fails, its objects and its rows are deleted.
4
4
  import { randomUUID } from 'node:crypto'
5
5
  import { fileTypeFromBuffer } from 'file-type'
6
- import type { Identity, JsonValue } from 'ultimabase'
6
+ import type { Identity, JsonValue, QueryOptions } from 'ultimabase'
7
7
  import { isMaster, PublicError } from 'ultimabase'
8
8
  import type { FileInput, FileOptions, Inline } from './field.ts'
9
9
  import { bytesOf } from './memory.ts'
@@ -13,13 +13,12 @@ import { settled } from './storage.ts'
13
13
  import { runningOf } from './values.ts'
14
14
 
15
15
  /** What an upload says of its file. */
16
- export interface UploadOptions {
16
+ export interface UploadOptions extends QueryOptions {
17
17
  /** The field the file is for: `Post.cover`. */
18
18
  readonly for: string
19
19
  readonly name: string
20
20
  /** The declared `Content-Type`, read only when the bytes say none. */
21
21
  readonly type?: string | undefined
22
- readonly as?: Identity | undefined
23
22
  }
24
23
 
25
24
  /** How many first bytes the type is read from, as `file-type` reads them. */
package/src/values.ts CHANGED
@@ -21,7 +21,7 @@ export interface FileValue<
21
21
  readonly name: string
22
22
  readonly type: string
23
23
  readonly size: number
24
- /** Stable for a public file, presigned for one hour for a private one. */
24
+ /** Stable for a public file, presigned for a private one, the same for 15 minutes. */
25
25
  readonly url: string
26
26
  readonly inline: I
27
27
  readonly variants: V