@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 +21 -0
- package/dist/disk.d.ts +13 -0
- package/dist/disk.d.ts.map +1 -0
- package/dist/disk.js +100 -0
- package/dist/field.d.ts +1 -1
- package/dist/field.d.ts.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/memory.d.ts +7 -2
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +12 -5
- package/dist/plugin.d.ts +2 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +8 -4
- package/dist/routes.d.ts +2 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +9 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +7 -4
- package/dist/served.d.ts +23 -0
- package/dist/served.d.ts.map +1 -0
- package/dist/served.js +62 -0
- package/dist/sigv4.d.ts +2 -2
- package/dist/sigv4.d.ts.map +1 -1
- package/dist/sigv4.js +3 -3
- package/dist/storage.d.ts +19 -3
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +9 -3
- package/dist/upload.d.ts +2 -3
- package/dist/upload.d.ts.map +1 -1
- package/dist/values.d.ts +1 -1
- package/dist/values.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/disk.ts +128 -0
- package/src/field.ts +1 -1
- package/src/index.ts +6 -4
- package/src/memory.ts +19 -8
- package/src/plugin.ts +9 -7
- package/src/routes.ts +18 -1
- package/src/s3.ts +8 -3
- package/src/served.ts +101 -0
- package/src/sigv4.ts +7 -3
- package/src/storage.ts +27 -6
- package/src/upload.ts +2 -3
- package/src/values.ts +1 -1
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
|
|
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;
|
package/dist/field.d.ts.map
CHANGED
|
@@ -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,
|
|
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 {
|
|
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';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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
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
|
-
/**
|
|
15
|
-
export
|
|
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
|
package/dist/memory.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"
|
|
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
|
-
|
|
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
|
|
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:
|
|
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,
|
|
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: {
|
package/dist/plugin.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
package/dist/routes.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"
|
|
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,
|
|
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
|
-
|
|
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
|
package/dist/served.d.ts
ADDED
|
@@ -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
|
|
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
|
package/dist/sigv4.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
|
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(
|
|
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
|
-
/**
|
|
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
|
|
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
|
/**
|
package/dist/storage.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
|
2
|
-
// memory for tests. A key starts with `public/` or
|
|
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 {
|
|
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,
|
package/dist/upload.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,
|
|
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
|
|
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;
|
package/dist/values.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"values.d.ts","sourceRoot":"","sources":["../src/values.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAC/D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAC7C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAE/C,sCAAsC;AACtC,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CACpB;AAED,uCAAuC;AACvC,MAAM,WAAW,SAAS,CACzB,CAAC,SAAS,MAAM,GAAG;IAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAAA;CAAE,EAC/D,CAAC,SAAS,MAAM,GAAG;IAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAAA;CAAE;IAEjE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,
|
|
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
|
+
"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": "
|
|
38
|
-
"ultimabase": "
|
|
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.
|
|
43
|
-
"@ultimabase/postgres": "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.
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
/**
|
|
25
|
-
export
|
|
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:
|
|
44
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
|
39
|
-
export function presign(
|
|
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(
|
|
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
|
|
2
|
-
// memory for tests. A key starts with `public/` or
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|