@ultimat3/storage 1.2.0 → 2.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.
@@ -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
- export const IMAGE_CONTENT_TYPES = [
14
- 'image/png',
15
- 'image/jpeg',
16
- 'image/gif',
17
- 'image/webp',
18
- 'image/svg+xml',
19
- ] as const;
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',