@ultimat3/storage 1.2.0 → 3.0.0
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/CLAUDE.md +186 -0
- package/README.md +165 -7
- package/package.json +3 -2
- package/src/accept.ts +149 -0
- package/src/attachment.ts +213 -0
- package/src/driver-local.ts +187 -29
- package/src/driver-s3-fixture.ts +164 -0
- package/src/driver-s3.ts +142 -25
- package/src/driver.ts +126 -11
- package/src/errors.ts +169 -2
- package/src/grant.ts +100 -0
- package/src/index.ts +64 -1
- package/src/path.ts +29 -1
- package/src/signed-url.ts +45 -16
- package/src/upload-client.ts +172 -0
- package/src/upload.ts +9 -7
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Single responsibility: the browser half of one upload — ask the server for a grant, PUT the
|
|
2
|
+
// bytes at it, hand back the storage key. Browser-safe by construction: no `Bun.*`, no `node:`,
|
|
3
|
+
// no driver import, so this file bundles into the client the way `@ultimat3/action`'s does.
|
|
4
|
+
//
|
|
5
|
+
// The default transport is `XMLHttpRequest`, not `fetch`, for exactly one reason: `fetch` reports
|
|
6
|
+
// no upload progress in any shipping browser, and a progress bar that jumps 0 -> 100 is a
|
|
7
|
+
// progress bar that is lying. `fetch` is the fallback where XHR does not exist.
|
|
8
|
+
|
|
9
|
+
import { tooLarge, uploadFailed } from './errors';
|
|
10
|
+
import type { UploadGrant, UploadRequest } from './grant';
|
|
11
|
+
|
|
12
|
+
export interface UploadProgress {
|
|
13
|
+
readonly loaded: number;
|
|
14
|
+
readonly total: number;
|
|
15
|
+
/** 0..1, and 0 when the total is unknown — never NaN, which formats as "NaN%". */
|
|
16
|
+
readonly ratio: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Structural `File`: what an `<input type="file">` hands over, with no DOM lib in the contract. */
|
|
20
|
+
export interface UploadSource extends Blob {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SignedPutInput {
|
|
25
|
+
readonly url: string;
|
|
26
|
+
readonly contentType: string;
|
|
27
|
+
readonly body: Blob;
|
|
28
|
+
readonly onProgress?: ((progress: UploadProgress) => void) | undefined;
|
|
29
|
+
readonly signal?: AbortSignal | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The seam a test injects and a non-browser host replaces. Resolves only on a 2xx. */
|
|
33
|
+
export type SignedPut = (input: SignedPutInput) => Promise<void>;
|
|
34
|
+
|
|
35
|
+
const progressOf = (loaded: number, total: number): UploadProgress => ({
|
|
36
|
+
loaded,
|
|
37
|
+
total,
|
|
38
|
+
ratio: total > 0 ? Math.min(loaded / total, 1) : 0,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** The path without the query — a signed URL's parameters carry the HMAC, so they never log. */
|
|
42
|
+
const pathOf = (url: string): string => {
|
|
43
|
+
try {
|
|
44
|
+
return new URL(url, 'http://storage.invalid').pathname;
|
|
45
|
+
} catch {
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const aborted = (url: string): ReturnType<typeof uploadFailed> =>
|
|
51
|
+
uploadFailed(pathOf(url), 0, 'the upload was aborted');
|
|
52
|
+
|
|
53
|
+
export const xhrSignedPut: SignedPut = (input) =>
|
|
54
|
+
new Promise<void>((resolve, reject) => {
|
|
55
|
+
// Asked BEFORE anything is opened: per spec, adding an `abort` listener to a signal that has
|
|
56
|
+
// already aborted never fires it, so the whole body used to go up for a caller who had already
|
|
57
|
+
// given up — the one case the listener below cannot cover.
|
|
58
|
+
if (input.signal?.aborted === true) {
|
|
59
|
+
reject(aborted(input.url));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const request = new XMLHttpRequest();
|
|
64
|
+
const onAbortRequested = (): void => {
|
|
65
|
+
request.abort();
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Every terminal outcome releases the signal listener. An `AbortSignal` is normally one per
|
|
69
|
+
* picker session and an upload is one per file, so a listener that is only ever added left one
|
|
70
|
+
* behind per call for the life of the signal — and each one retains its `XMLHttpRequest`.
|
|
71
|
+
*/
|
|
72
|
+
const settle =
|
|
73
|
+
(answer: () => void): (() => void) =>
|
|
74
|
+
() => {
|
|
75
|
+
input.signal?.removeEventListener('abort', onAbortRequested);
|
|
76
|
+
answer();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
request.open('PUT', input.url, true);
|
|
80
|
+
request.setRequestHeader('content-type', input.contentType);
|
|
81
|
+
request.upload.addEventListener('progress', (event: ProgressEvent) => {
|
|
82
|
+
input.onProgress?.(progressOf(event.loaded, event.lengthComputable ? event.total : 0));
|
|
83
|
+
});
|
|
84
|
+
request.addEventListener(
|
|
85
|
+
'load',
|
|
86
|
+
settle(() => {
|
|
87
|
+
if (request.status >= 200 && request.status < 300) {
|
|
88
|
+
input.onProgress?.(progressOf(input.body.size, input.body.size));
|
|
89
|
+
resolve();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
reject(uploadFailed(pathOf(input.url), request.status, request.responseText));
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
// A transport fault carries no status; 0 is the one value no server can answer with.
|
|
96
|
+
request.addEventListener(
|
|
97
|
+
'error',
|
|
98
|
+
settle(() => {
|
|
99
|
+
reject(uploadFailed(pathOf(input.url), 0, 'the request never reached the disk'));
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
request.addEventListener(
|
|
103
|
+
'abort',
|
|
104
|
+
settle(() => {
|
|
105
|
+
reject(aborted(input.url));
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
input.signal?.addEventListener('abort', onAbortRequested);
|
|
109
|
+
request.send(input.body);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** No upload progress — `onProgress` fires once at 0 and once at 1, and says so by doing that. */
|
|
113
|
+
export const fetchSignedPut: SignedPut = async (input) => {
|
|
114
|
+
input.onProgress?.(progressOf(0, input.body.size));
|
|
115
|
+
const response = await fetch(input.url, {
|
|
116
|
+
method: 'PUT',
|
|
117
|
+
headers: { 'content-type': input.contentType },
|
|
118
|
+
body: input.body,
|
|
119
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
120
|
+
});
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
throw uploadFailed(pathOf(input.url), response.status, await response.text());
|
|
123
|
+
}
|
|
124
|
+
input.onProgress?.(progressOf(input.body.size, input.body.size));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const defaultSignedPut = (): SignedPut =>
|
|
128
|
+
typeof XMLHttpRequest === 'undefined' ? fetchSignedPut : xhrSignedPut;
|
|
129
|
+
|
|
130
|
+
export interface UploadFileInput {
|
|
131
|
+
readonly file: UploadSource;
|
|
132
|
+
/**
|
|
133
|
+
* The app's own grant call — the typed client of the `action` that wraps `grantUpload`. Passed
|
|
134
|
+
* in rather than fetched from a convention path: the policy behind it is the app's, and this
|
|
135
|
+
* package has no way to know which route it was projected onto.
|
|
136
|
+
*/
|
|
137
|
+
readonly grant: (request: UploadRequest) => Promise<UploadGrant>;
|
|
138
|
+
readonly onProgress?: ((progress: UploadProgress) => void) | undefined;
|
|
139
|
+
readonly signal?: AbortSignal | undefined;
|
|
140
|
+
readonly put?: SignedPut | undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface UploadedFile {
|
|
144
|
+
/** What the app stores on the row. The URL is a capability and is deliberately not returned. */
|
|
145
|
+
readonly key: string;
|
|
146
|
+
readonly contentType: string;
|
|
147
|
+
readonly size: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One file, one round trip each way. The size is re-checked against the grant before a byte
|
|
152
|
+
* moves — the server enforces it again on arrival, and this only spares the user a full upload
|
|
153
|
+
* that was always going to be refused.
|
|
154
|
+
*/
|
|
155
|
+
export async function uploadFile(input: UploadFileInput): Promise<UploadedFile> {
|
|
156
|
+
const grant = await input.grant({
|
|
157
|
+
filename: input.file.name,
|
|
158
|
+
contentType: input.file.type,
|
|
159
|
+
size: input.file.size,
|
|
160
|
+
});
|
|
161
|
+
if (input.file.size > grant.maxBytes) {
|
|
162
|
+
throw tooLarge(grant.key, input.file.size, grant.maxBytes);
|
|
163
|
+
}
|
|
164
|
+
await (input.put ?? defaultSignedPut())({
|
|
165
|
+
url: grant.url,
|
|
166
|
+
contentType: grant.contentType,
|
|
167
|
+
body: input.file,
|
|
168
|
+
...(input.onProgress === undefined ? {} : { onProgress: input.onProgress }),
|
|
169
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
170
|
+
});
|
|
171
|
+
return { key: grant.key, contentType: grant.contentType, size: input.file.size };
|
|
172
|
+
}
|
package/src/upload.ts
CHANGED
|
@@ -10,13 +10,15 @@ import { assertSafeKey } from './path';
|
|
|
10
10
|
|
|
11
11
|
export const DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
/**
|
|
14
|
+
* `image/svg+xml` is deliberately ABSENT, and this is the default `uploadPolicy()` allowlist. An
|
|
15
|
+
* SVG is a script document: served back from the app's own origin under its declared type it runs
|
|
16
|
+
* on that origin, and the sniffer below PROMOTES a `<svg` body to this type rather than refusing
|
|
17
|
+
* it — so every app taking the default was accepting stored XSS, cached by the asset route for a
|
|
18
|
+
* year. An app that genuinely serves user SVG declares it once, explicitly, in
|
|
19
|
+
* `uploadPolicy({ allowedContentTypes })`, having decided how it serves the bytes back.
|
|
20
|
+
*/
|
|
21
|
+
export const IMAGE_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'] as const;
|
|
20
22
|
|
|
21
23
|
export const DOCUMENT_CONTENT_TYPES = [
|
|
22
24
|
'application/pdf',
|