@lunora/storage 1.0.0-alpha.11 → 1.0.0-alpha.110
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/README.md +15 -7
- package/dist/index.d.mts +130 -169
- package/dist/index.d.ts +130 -169
- package/dist/index.mjs +1 -4
- package/dist/packem_shared/DEFAULT_MAX_UPLOAD_BYTES-BlLcQ6Uc.mjs +1 -0
- package/dist/packem_shared/R2_PART_SIZE-iEB-PTmG.mjs +1 -0
- package/dist/packem_shared/buildPresignedUrl-U55ktL8x.mjs +4 -0
- package/dist/packem_shared/buildSignedUrl-czz1xjLU.mjs +6 -0
- package/dist/packem_shared/createBucketStorage-AqIMq050.mjs +1 -0
- package/dist/packem_shared/createR2BindingUploadStorage-BQE2iEz6.mjs +1 -0
- package/dist/packem_shared/createStorage-YPzdUgGl.mjs +1 -0
- package/dist/packem_shared/internal-B5yyxF10.mjs +1 -0
- package/dist/packem_shared/r2-upload-part-writer-BE4HJRLY.mjs +1 -0
- package/dist/upload.d.mts +129 -4
- package/dist/upload.d.ts +129 -4
- package/dist/upload.mjs +1 -1
- package/package.json +4 -3
- package/dist/packem_shared/buildPresignedUrl-B5f1wvz6.mjs +0 -64
- package/dist/packem_shared/buildSignedUrl-rt-6azia.mjs +0 -124
- package/dist/packem_shared/createBucketStorage-JYdrS4e5.mjs +0 -28
- package/dist/packem_shared/createR2UploadStorage-BfLIpWdf.mjs +0 -54
- package/dist/packem_shared/createStorage-Bu6GJB25.mjs +0 -261
- package/dist/packem_shared/internal-Dqk0MrAj.mjs +0 -17
package/dist/upload.d.mts
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
|
+
import { BaseStorageOptions } from '@visulima/storage';
|
|
2
|
+
import { R2BucketLike } from '@lunora/platform';
|
|
1
3
|
import { Tus } from '@visulima/storage/handler/http/fetch';
|
|
2
4
|
import { AwsLightStorage } from '@visulima/storage/provider/aws-light';
|
|
5
|
+
/**
|
|
6
|
+
* An R2 binding with the multipart API: a Worker's `env.BUCKET` (and
|
|
7
|
+
* miniflare's). The provider also relies on `put`'s `onlyIf` preconditions,
|
|
8
|
+
* which every R2 binding honors.
|
|
9
|
+
*/
|
|
10
|
+
type R2UploadBucket = R2BucketLike & Required<Pick<R2BucketLike, "createMultipartUpload" | "resumeMultipartUpload">>;
|
|
3
11
|
/** Resumable upload wire protocols the handler can speak. */
|
|
4
12
|
type UploadProtocol = "chunked-rest" | "multipart" | "tus";
|
|
13
|
+
/**
|
|
14
|
+
* Default ceiling applied to `maxFileSize` when the caller doesn't supply one
|
|
15
|
+
* (100 MiB). Without SOME cap, an unauthenticated or loosely-authorized
|
|
16
|
+
* handler accepts an unbounded request body — pass an explicit `maxFileSize`
|
|
17
|
+
* to raise or lower this for your app.
|
|
18
|
+
*/
|
|
19
|
+
declare const DEFAULT_MAX_UPLOAD_BYTES: number;
|
|
5
20
|
type UploadHandlerOptions = ConstructorParameters<typeof Tus>[0];
|
|
6
21
|
/** A `@visulima/storage` storage provider (e.g. {@link createR2UploadStorage} or a memory provider in tests). */
|
|
7
22
|
type UploadStorage = UploadHandlerOptions["storage"];
|
|
@@ -20,6 +35,29 @@ interface UploadAuthzContext {
|
|
|
20
35
|
/** The parsed request URL (query params, upload-id path segment). */
|
|
21
36
|
url: URL;
|
|
22
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* The context handed to {@link CreateUploadHandlerOptions.maxFileSizeFor}: the
|
|
40
|
+
* authorization context plus what the create request declares about the file.
|
|
41
|
+
* Everything here comes from the client, so it caps an upload by what the
|
|
42
|
+
* client SAYS it is; `allowMIME` on the provider is what checks the type.
|
|
43
|
+
*/
|
|
44
|
+
interface UploadSizeContext extends UploadAuthzContext {
|
|
45
|
+
/**
|
|
46
|
+
* The declared MIME type, resolved the way the stored file's type is: on
|
|
47
|
+
* chunked REST the request's `Content-Type`; on TUS the `Upload-Metadata`
|
|
48
|
+
* `mimeType`, else `type`, else `filetype`; else
|
|
49
|
+
* `application/octet-stream`.
|
|
50
|
+
*/
|
|
51
|
+
contentType: string;
|
|
52
|
+
/**
|
|
53
|
+
* The largest size the request declares (`Upload-Length`, `X-Total-Size`,
|
|
54
|
+
* `Content-Length`). A declared size that is not a non-negative integer
|
|
55
|
+
* reads as `Infinity`.
|
|
56
|
+
*/
|
|
57
|
+
declaredSize: number | undefined;
|
|
58
|
+
/** The decoded TUS `Upload-Metadata`, or the chunked-REST `X-File-Metadata` JSON, as strings. */
|
|
59
|
+
metadata: Record<string, string>;
|
|
60
|
+
}
|
|
23
61
|
/** Options for {@link createUploadHandler}. */
|
|
24
62
|
interface CreateUploadHandlerOptions {
|
|
25
63
|
/**
|
|
@@ -27,12 +65,51 @@ interface CreateUploadHandlerOptions {
|
|
|
27
65
|
* returning `false` **or throwing** yields a `403`. Omit only for a fully
|
|
28
66
|
* public bucket — the whole point of this handler over the admin path is
|
|
29
67
|
* that uploads are gated by *your* per-user policy, not an admin token.
|
|
68
|
+
*
|
|
69
|
+
* Omitting it mounts an unauthenticated, unbounded-write endpoint, so
|
|
70
|
+
* doing so logs a one-time warning (per handler) unless `silent`/`public`
|
|
71
|
+
* says the omission is intentional.
|
|
30
72
|
*/
|
|
31
73
|
authorize?: (context: UploadAuthzContext) => boolean | Promise<boolean>;
|
|
32
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Maximum accepted file size in bytes. Forwarded to the multipart parser
|
|
76
|
+
* (protocol `"multipart"`) and, for `"tus"`/`"chunked-rest"`, enforced by
|
|
77
|
+
* this handler itself against the largest size the request declares across
|
|
78
|
+
* `Upload-Length` (TUS), `X-Total-Size` (chunked REST) and `Content-Length`
|
|
79
|
+
* — see {@link declaredUploadSize}. Defaults to
|
|
80
|
+
* {@link DEFAULT_MAX_UPLOAD_BYTES} (100 MiB) — pass this to raise or lower
|
|
81
|
+
* the ceiling; there is no unbounded option. Must be a finite, non-negative
|
|
82
|
+
* number: anything else (notably a `NaN` from an unset env var) throws at
|
|
83
|
+
* construction rather than disabling the cap.
|
|
84
|
+
*/
|
|
33
85
|
maxFileSize?: number;
|
|
86
|
+
/**
|
|
87
|
+
* A per-upload size cap, below `maxFileSize`: return the most bytes this
|
|
88
|
+
* upload may hold (say 10 MiB for `image/*`, 2 GiB for `video/*`), or
|
|
89
|
+
* `undefined` to leave `maxFileSize` as the only cap. It can only lower the
|
|
90
|
+
* cap, never raise it past `maxFileSize`.
|
|
91
|
+
*
|
|
92
|
+
* Runs after `authorize`, on the requests that create an upload (`POST`,
|
|
93
|
+
* and the chunked-REST `PUT`) of the `"tus"` and `"chunked-rest"`
|
|
94
|
+
* protocols; later chunks are bounded by the size declared at creation.
|
|
95
|
+
* Not applied to `"multipart"`, whose size is only known once the form is
|
|
96
|
+
* parsed. A create that declares no size is refused (`413`) when this
|
|
97
|
+
* returns a cap, since there is nothing to check it against. Throwing, or
|
|
98
|
+
* returning something that is not a finite, non-negative number, denies the
|
|
99
|
+
* request (`403`), fail-closed like `authorize`.
|
|
100
|
+
*/
|
|
101
|
+
maxFileSizeFor?: (context: UploadSizeContext) => number | undefined | Promise<number | undefined>;
|
|
34
102
|
/** Which protocol to speak. Default `"tus"` (the resumable, pause/resume-capable one). */
|
|
35
103
|
protocol?: UploadProtocol;
|
|
104
|
+
/**
|
|
105
|
+
* Set when omitting `authorize` is intentional (a fully public upload
|
|
106
|
+
* bucket) — suppresses the one-time "no authorize gate" warning that would
|
|
107
|
+
* otherwise print when the handler is constructed. Has no effect when
|
|
108
|
+
* `authorize` is provided.
|
|
109
|
+
*/
|
|
110
|
+
public?: boolean;
|
|
111
|
+
/** Suppress the one-time default-open-authorize warning. Alias of `public`. */
|
|
112
|
+
silent?: boolean;
|
|
36
113
|
/** The storage provider the bytes land in (R2 in prod, memory in tests). */
|
|
37
114
|
storage: UploadStorage;
|
|
38
115
|
}
|
|
@@ -57,8 +134,8 @@ interface R2UploadStorageOptions {
|
|
|
57
134
|
bucket: string;
|
|
58
135
|
/**
|
|
59
136
|
* Explicit R2 S3 endpoint. Defaults to
|
|
60
|
-
* `https
|
|
61
|
-
* jurisdiction (e.g.
|
|
137
|
+
* `https://<accountId>.r2.cloudflarestorage.com`. Pass this to pin a
|
|
138
|
+
* jurisdiction (e.g. `<accountId>.eu.r2.cloudflarestorage.com`).
|
|
62
139
|
*/
|
|
63
140
|
endpoint?: string;
|
|
64
141
|
/** Client-side multipart part size (bytes or a size string like `"16MB"`). */
|
|
@@ -88,4 +165,52 @@ declare const createUploadHandler: (options: CreateUploadHandlerOptions) => Uplo
|
|
|
88
165
|
declare const createR2UploadStorage: (options: R2UploadStorageOptions & {
|
|
89
166
|
secretAccessKey: string;
|
|
90
167
|
}) => AwsLightStorage;
|
|
91
|
-
|
|
168
|
+
/** Every part of an R2 multipart upload but the last is exactly this size: R2's 5 MiB minimum. */
|
|
169
|
+
declare const R2_PART_SIZE: number;
|
|
170
|
+
/** Options for {@link createR2BindingUploadStorage}. */
|
|
171
|
+
interface R2BindingUploadStorageOptions extends Omit<BaseStorageOptions, "metaStorage"> {
|
|
172
|
+
/**
|
|
173
|
+
* Key prefix the upload state objects and buffered segments are stored
|
|
174
|
+
* under, in the same bucket. Default `"_lunora/uploads/"`. No uploaded
|
|
175
|
+
* object may be named under it.
|
|
176
|
+
*
|
|
177
|
+
* The state of a finished upload stays (a few hundred bytes, so a `HEAD`
|
|
178
|
+
* can still answer that the upload is complete), and so does the state of
|
|
179
|
+
* one that was abandoned. Both accumulate: add an R2 object lifecycle rule
|
|
180
|
+
* that deletes objects under this prefix after a few days, longer than
|
|
181
|
+
* any upload you expect to take.
|
|
182
|
+
*/
|
|
183
|
+
statePrefix?: string;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Build a resumable-upload provider for `createUploadHandler` over a Worker's
|
|
187
|
+
* R2 binding: no S3 credentials, and the same behaviour under `wrangler dev` /
|
|
188
|
+
* miniflare as in production.
|
|
189
|
+
*
|
|
190
|
+
* Speaks TUS (without the `checksum`, `concatenation` and
|
|
191
|
+
* `creation-defer-length` extensions), chunked REST with chunks sent in order
|
|
192
|
+
* (a chunk at any other offset is a `409`), and multipart forms.
|
|
193
|
+
*
|
|
194
|
+
* Chunked REST is broken upstream, see visulima/visulima#884 and the
|
|
195
|
+
* `@lunora/storage` docs ("Chunked REST is broken upstream").
|
|
196
|
+
*/
|
|
197
|
+
declare const createR2BindingUploadStorage: (bucket: R2UploadBucket, options?: R2BindingUploadStorageOptions) => UploadStorage;
|
|
198
|
+
export { type CreateUploadHandlerOptions, DEFAULT_MAX_UPLOAD_BYTES,
|
|
199
|
+
/**
|
|
200
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
201
|
+
*
|
|
202
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
203
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
204
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
205
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
206
|
+
*/
|
|
207
|
+
type R2BindingUploadStorageOptions,
|
|
208
|
+
/**
|
|
209
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
210
|
+
*
|
|
211
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
212
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
213
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
214
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
215
|
+
*/
|
|
216
|
+
type R2UploadBucket, type R2UploadStorageOptions, R2_PART_SIZE, type UploadAuthzContext, type UploadHandler, type UploadProtocol, type UploadSizeContext, type UploadStorage, createR2BindingUploadStorage, createR2UploadStorage, createUploadHandler };
|
package/dist/upload.d.ts
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
|
+
import { BaseStorageOptions } from '@visulima/storage';
|
|
2
|
+
import { R2BucketLike } from '@lunora/platform';
|
|
1
3
|
import { Tus } from '@visulima/storage/handler/http/fetch';
|
|
2
4
|
import { AwsLightStorage } from '@visulima/storage/provider/aws-light';
|
|
5
|
+
/**
|
|
6
|
+
* An R2 binding with the multipart API: a Worker's `env.BUCKET` (and
|
|
7
|
+
* miniflare's). The provider also relies on `put`'s `onlyIf` preconditions,
|
|
8
|
+
* which every R2 binding honors.
|
|
9
|
+
*/
|
|
10
|
+
type R2UploadBucket = R2BucketLike & Required<Pick<R2BucketLike, "createMultipartUpload" | "resumeMultipartUpload">>;
|
|
3
11
|
/** Resumable upload wire protocols the handler can speak. */
|
|
4
12
|
type UploadProtocol = "chunked-rest" | "multipart" | "tus";
|
|
13
|
+
/**
|
|
14
|
+
* Default ceiling applied to `maxFileSize` when the caller doesn't supply one
|
|
15
|
+
* (100 MiB). Without SOME cap, an unauthenticated or loosely-authorized
|
|
16
|
+
* handler accepts an unbounded request body — pass an explicit `maxFileSize`
|
|
17
|
+
* to raise or lower this for your app.
|
|
18
|
+
*/
|
|
19
|
+
declare const DEFAULT_MAX_UPLOAD_BYTES: number;
|
|
5
20
|
type UploadHandlerOptions = ConstructorParameters<typeof Tus>[0];
|
|
6
21
|
/** A `@visulima/storage` storage provider (e.g. {@link createR2UploadStorage} or a memory provider in tests). */
|
|
7
22
|
type UploadStorage = UploadHandlerOptions["storage"];
|
|
@@ -20,6 +35,29 @@ interface UploadAuthzContext {
|
|
|
20
35
|
/** The parsed request URL (query params, upload-id path segment). */
|
|
21
36
|
url: URL;
|
|
22
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* The context handed to {@link CreateUploadHandlerOptions.maxFileSizeFor}: the
|
|
40
|
+
* authorization context plus what the create request declares about the file.
|
|
41
|
+
* Everything here comes from the client, so it caps an upload by what the
|
|
42
|
+
* client SAYS it is; `allowMIME` on the provider is what checks the type.
|
|
43
|
+
*/
|
|
44
|
+
interface UploadSizeContext extends UploadAuthzContext {
|
|
45
|
+
/**
|
|
46
|
+
* The declared MIME type, resolved the way the stored file's type is: on
|
|
47
|
+
* chunked REST the request's `Content-Type`; on TUS the `Upload-Metadata`
|
|
48
|
+
* `mimeType`, else `type`, else `filetype`; else
|
|
49
|
+
* `application/octet-stream`.
|
|
50
|
+
*/
|
|
51
|
+
contentType: string;
|
|
52
|
+
/**
|
|
53
|
+
* The largest size the request declares (`Upload-Length`, `X-Total-Size`,
|
|
54
|
+
* `Content-Length`). A declared size that is not a non-negative integer
|
|
55
|
+
* reads as `Infinity`.
|
|
56
|
+
*/
|
|
57
|
+
declaredSize: number | undefined;
|
|
58
|
+
/** The decoded TUS `Upload-Metadata`, or the chunked-REST `X-File-Metadata` JSON, as strings. */
|
|
59
|
+
metadata: Record<string, string>;
|
|
60
|
+
}
|
|
23
61
|
/** Options for {@link createUploadHandler}. */
|
|
24
62
|
interface CreateUploadHandlerOptions {
|
|
25
63
|
/**
|
|
@@ -27,12 +65,51 @@ interface CreateUploadHandlerOptions {
|
|
|
27
65
|
* returning `false` **or throwing** yields a `403`. Omit only for a fully
|
|
28
66
|
* public bucket — the whole point of this handler over the admin path is
|
|
29
67
|
* that uploads are gated by *your* per-user policy, not an admin token.
|
|
68
|
+
*
|
|
69
|
+
* Omitting it mounts an unauthenticated, unbounded-write endpoint, so
|
|
70
|
+
* doing so logs a one-time warning (per handler) unless `silent`/`public`
|
|
71
|
+
* says the omission is intentional.
|
|
30
72
|
*/
|
|
31
73
|
authorize?: (context: UploadAuthzContext) => boolean | Promise<boolean>;
|
|
32
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Maximum accepted file size in bytes. Forwarded to the multipart parser
|
|
76
|
+
* (protocol `"multipart"`) and, for `"tus"`/`"chunked-rest"`, enforced by
|
|
77
|
+
* this handler itself against the largest size the request declares across
|
|
78
|
+
* `Upload-Length` (TUS), `X-Total-Size` (chunked REST) and `Content-Length`
|
|
79
|
+
* — see {@link declaredUploadSize}. Defaults to
|
|
80
|
+
* {@link DEFAULT_MAX_UPLOAD_BYTES} (100 MiB) — pass this to raise or lower
|
|
81
|
+
* the ceiling; there is no unbounded option. Must be a finite, non-negative
|
|
82
|
+
* number: anything else (notably a `NaN` from an unset env var) throws at
|
|
83
|
+
* construction rather than disabling the cap.
|
|
84
|
+
*/
|
|
33
85
|
maxFileSize?: number;
|
|
86
|
+
/**
|
|
87
|
+
* A per-upload size cap, below `maxFileSize`: return the most bytes this
|
|
88
|
+
* upload may hold (say 10 MiB for `image/*`, 2 GiB for `video/*`), or
|
|
89
|
+
* `undefined` to leave `maxFileSize` as the only cap. It can only lower the
|
|
90
|
+
* cap, never raise it past `maxFileSize`.
|
|
91
|
+
*
|
|
92
|
+
* Runs after `authorize`, on the requests that create an upload (`POST`,
|
|
93
|
+
* and the chunked-REST `PUT`) of the `"tus"` and `"chunked-rest"`
|
|
94
|
+
* protocols; later chunks are bounded by the size declared at creation.
|
|
95
|
+
* Not applied to `"multipart"`, whose size is only known once the form is
|
|
96
|
+
* parsed. A create that declares no size is refused (`413`) when this
|
|
97
|
+
* returns a cap, since there is nothing to check it against. Throwing, or
|
|
98
|
+
* returning something that is not a finite, non-negative number, denies the
|
|
99
|
+
* request (`403`), fail-closed like `authorize`.
|
|
100
|
+
*/
|
|
101
|
+
maxFileSizeFor?: (context: UploadSizeContext) => number | undefined | Promise<number | undefined>;
|
|
34
102
|
/** Which protocol to speak. Default `"tus"` (the resumable, pause/resume-capable one). */
|
|
35
103
|
protocol?: UploadProtocol;
|
|
104
|
+
/**
|
|
105
|
+
* Set when omitting `authorize` is intentional (a fully public upload
|
|
106
|
+
* bucket) — suppresses the one-time "no authorize gate" warning that would
|
|
107
|
+
* otherwise print when the handler is constructed. Has no effect when
|
|
108
|
+
* `authorize` is provided.
|
|
109
|
+
*/
|
|
110
|
+
public?: boolean;
|
|
111
|
+
/** Suppress the one-time default-open-authorize warning. Alias of `public`. */
|
|
112
|
+
silent?: boolean;
|
|
36
113
|
/** The storage provider the bytes land in (R2 in prod, memory in tests). */
|
|
37
114
|
storage: UploadStorage;
|
|
38
115
|
}
|
|
@@ -57,8 +134,8 @@ interface R2UploadStorageOptions {
|
|
|
57
134
|
bucket: string;
|
|
58
135
|
/**
|
|
59
136
|
* Explicit R2 S3 endpoint. Defaults to
|
|
60
|
-
* `https
|
|
61
|
-
* jurisdiction (e.g.
|
|
137
|
+
* `https://<accountId>.r2.cloudflarestorage.com`. Pass this to pin a
|
|
138
|
+
* jurisdiction (e.g. `<accountId>.eu.r2.cloudflarestorage.com`).
|
|
62
139
|
*/
|
|
63
140
|
endpoint?: string;
|
|
64
141
|
/** Client-side multipart part size (bytes or a size string like `"16MB"`). */
|
|
@@ -88,4 +165,52 @@ declare const createUploadHandler: (options: CreateUploadHandlerOptions) => Uplo
|
|
|
88
165
|
declare const createR2UploadStorage: (options: R2UploadStorageOptions & {
|
|
89
166
|
secretAccessKey: string;
|
|
90
167
|
}) => AwsLightStorage;
|
|
91
|
-
|
|
168
|
+
/** Every part of an R2 multipart upload but the last is exactly this size: R2's 5 MiB minimum. */
|
|
169
|
+
declare const R2_PART_SIZE: number;
|
|
170
|
+
/** Options for {@link createR2BindingUploadStorage}. */
|
|
171
|
+
interface R2BindingUploadStorageOptions extends Omit<BaseStorageOptions, "metaStorage"> {
|
|
172
|
+
/**
|
|
173
|
+
* Key prefix the upload state objects and buffered segments are stored
|
|
174
|
+
* under, in the same bucket. Default `"_lunora/uploads/"`. No uploaded
|
|
175
|
+
* object may be named under it.
|
|
176
|
+
*
|
|
177
|
+
* The state of a finished upload stays (a few hundred bytes, so a `HEAD`
|
|
178
|
+
* can still answer that the upload is complete), and so does the state of
|
|
179
|
+
* one that was abandoned. Both accumulate: add an R2 object lifecycle rule
|
|
180
|
+
* that deletes objects under this prefix after a few days, longer than
|
|
181
|
+
* any upload you expect to take.
|
|
182
|
+
*/
|
|
183
|
+
statePrefix?: string;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Build a resumable-upload provider for `createUploadHandler` over a Worker's
|
|
187
|
+
* R2 binding: no S3 credentials, and the same behaviour under `wrangler dev` /
|
|
188
|
+
* miniflare as in production.
|
|
189
|
+
*
|
|
190
|
+
* Speaks TUS (without the `checksum`, `concatenation` and
|
|
191
|
+
* `creation-defer-length` extensions), chunked REST with chunks sent in order
|
|
192
|
+
* (a chunk at any other offset is a `409`), and multipart forms.
|
|
193
|
+
*
|
|
194
|
+
* Chunked REST is broken upstream, see visulima/visulima#884 and the
|
|
195
|
+
* `@lunora/storage` docs ("Chunked REST is broken upstream").
|
|
196
|
+
*/
|
|
197
|
+
declare const createR2BindingUploadStorage: (bucket: R2UploadBucket, options?: R2BindingUploadStorageOptions) => UploadStorage;
|
|
198
|
+
export { type CreateUploadHandlerOptions, DEFAULT_MAX_UPLOAD_BYTES,
|
|
199
|
+
/**
|
|
200
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
201
|
+
*
|
|
202
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
203
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
204
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
205
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
206
|
+
*/
|
|
207
|
+
type R2BindingUploadStorageOptions,
|
|
208
|
+
/**
|
|
209
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
210
|
+
*
|
|
211
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
212
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
213
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
214
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
215
|
+
*/
|
|
216
|
+
type R2UploadBucket, type R2UploadStorageOptions, R2_PART_SIZE, type UploadAuthzContext, type UploadHandler, type UploadProtocol, type UploadSizeContext, type UploadStorage, createR2BindingUploadStorage, createR2UploadStorage, createUploadHandler };
|
package/dist/upload.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
import{createR2BindingUploadStorage as o}from"./packem_shared/createR2BindingUploadStorage-BQE2iEz6.mjs";import{DEFAULT_MAX_UPLOAD_BYTES as t,createR2UploadStorage as p,createUploadHandler as d}from"./packem_shared/DEFAULT_MAX_UPLOAD_BYTES-BlLcQ6Uc.mjs";import{R as U}from"./packem_shared/r2-upload-part-writer-BE4HJRLY.mjs";export{t as DEFAULT_MAX_UPLOAD_BYTES,U as R2_PART_SIZE,o as createR2BindingUploadStorage,p as createR2UploadStorage,d as createUploadHandler};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/storage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.110",
|
|
4
4
|
"description": "R2-backed storage for Lunora: typed buckets and signed URLs",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -50,8 +50,9 @@
|
|
|
50
50
|
"access": "public"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@lunora/errors": "1.0.0-alpha.
|
|
54
|
-
"@
|
|
53
|
+
"@lunora/errors": "1.0.0-alpha.50",
|
|
54
|
+
"@lunora/platform": "1.0.0-alpha.55",
|
|
55
|
+
"@visulima/storage": "2.0.22",
|
|
55
56
|
"aws4fetch": "1.0.20"
|
|
56
57
|
},
|
|
57
58
|
"engines": {
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { a as toHex } from './internal-Dqk0MrAj.mjs';
|
|
2
|
-
|
|
3
|
-
const REGION = "auto";
|
|
4
|
-
const SERVICE = "s3";
|
|
5
|
-
const ALGORITHM = "AWS4-HMAC-SHA256";
|
|
6
|
-
const MIN_EXPIRES_SECONDS = 1;
|
|
7
|
-
const MAX_EXPIRES_SECONDS = 7 * 24 * 60 * 60;
|
|
8
|
-
const DEFAULT_EXPIRES_SECONDS = 900;
|
|
9
|
-
const textEncoder = new TextEncoder();
|
|
10
|
-
const compareEntries = (a, b) => {
|
|
11
|
-
if (a[0] < b[0]) {
|
|
12
|
-
return -1;
|
|
13
|
-
}
|
|
14
|
-
return a[0] > b[0] ? 1 : 0;
|
|
15
|
-
};
|
|
16
|
-
const encodeRfc3986 = (value) => encodeURIComponent(value).replaceAll(/[!'()*]/gu, (char) => `%${char.codePointAt(0)?.toString(16).toUpperCase() ?? ""}`);
|
|
17
|
-
const encodeKey = (key) => key.split("/").map((segment) => encodeRfc3986(segment)).join("/");
|
|
18
|
-
const sha256Hex = async (input) => toHex(await crypto.subtle.digest("SHA-256", textEncoder.encode(input)));
|
|
19
|
-
const hmac = async (key, message) => {
|
|
20
|
-
const cryptoKey = await crypto.subtle.importKey("raw", key, { hash: "SHA-256", name: "HMAC" }, false, ["sign"]);
|
|
21
|
-
return crypto.subtle.sign("HMAC", cryptoKey, textEncoder.encode(message));
|
|
22
|
-
};
|
|
23
|
-
const deriveSigningKey = async (secretAccessKey, dateStamp) => {
|
|
24
|
-
const dateKey = await hmac(textEncoder.encode(`AWS4${secretAccessKey}`), dateStamp);
|
|
25
|
-
const regionKey = await hmac(dateKey, REGION);
|
|
26
|
-
const serviceKey = await hmac(regionKey, SERVICE);
|
|
27
|
-
return hmac(serviceKey, "aws4_request");
|
|
28
|
-
};
|
|
29
|
-
const endpointHost = (credentials) => {
|
|
30
|
-
const infix = credentials.jurisdiction === void 0 ? "" : `${credentials.jurisdiction}.`;
|
|
31
|
-
return `${credentials.accountId}.${infix}r2.cloudflarestorage.com`;
|
|
32
|
-
};
|
|
33
|
-
const formatAmzDate = (date) => {
|
|
34
|
-
const amzDate = `${date.toISOString().replaceAll(/[:-]/gu, "").slice(0, 15)}Z`;
|
|
35
|
-
return { amzDate, dateStamp: amzDate.slice(0, 8) };
|
|
36
|
-
};
|
|
37
|
-
const buildPresignedUrl = async (parameters) => {
|
|
38
|
-
const { credentials, key } = parameters;
|
|
39
|
-
const method = parameters.method ?? "GET";
|
|
40
|
-
const requested = parameters.expiresInSeconds ?? DEFAULT_EXPIRES_SECONDS;
|
|
41
|
-
const normalised = Number.isFinite(requested) ? requested : DEFAULT_EXPIRES_SECONDS;
|
|
42
|
-
const expires = Math.min(Math.max(MIN_EXPIRES_SECONDS, Math.floor(normalised)), MAX_EXPIRES_SECONDS);
|
|
43
|
-
const host = endpointHost(credentials);
|
|
44
|
-
const date = new Date(parameters.now?.() ?? Date.now());
|
|
45
|
-
const { amzDate, dateStamp } = formatAmzDate(date);
|
|
46
|
-
const credentialScope = `${dateStamp}/${REGION}/${SERVICE}/aws4_request`;
|
|
47
|
-
const canonicalUri = `/${encodeRfc3986(credentials.bucket)}/${encodeKey(key)}`;
|
|
48
|
-
const query = [
|
|
49
|
-
["X-Amz-Algorithm", ALGORITHM],
|
|
50
|
-
["X-Amz-Credential", `${credentials.accessKeyId}/${credentialScope}`],
|
|
51
|
-
["X-Amz-Date", amzDate],
|
|
52
|
-
["X-Amz-Expires", expires.toString()],
|
|
53
|
-
["X-Amz-SignedHeaders", "host"]
|
|
54
|
-
];
|
|
55
|
-
const canonicalQuery = query.map(([name, value]) => [encodeRfc3986(name), encodeRfc3986(value)]).toSorted(compareEntries).map(([name, value]) => `${name}=${value}`).join("&");
|
|
56
|
-
const canonicalRequest = [method, canonicalUri, canonicalQuery, `host:${host}
|
|
57
|
-
`, "host", "UNSIGNED-PAYLOAD"].join("\n");
|
|
58
|
-
const stringToSign = [ALGORITHM, amzDate, credentialScope, await sha256Hex(canonicalRequest)].join("\n");
|
|
59
|
-
const signingKey = await deriveSigningKey(credentials.secretAccessKey, dateStamp);
|
|
60
|
-
const signature = toHex(await hmac(signingKey, stringToSign));
|
|
61
|
-
return `https://${host}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
export { buildPresignedUrl };
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
import { t as trimTrailingSlashes } from './internal-Dqk0MrAj.mjs';
|
|
3
|
-
|
|
4
|
-
const evictOldestEntry = (map, capacity) => {
|
|
5
|
-
if (map.size < capacity) {
|
|
6
|
-
return;
|
|
7
|
-
}
|
|
8
|
-
const oldest = map.keys().next().value;
|
|
9
|
-
if (oldest !== void 0) {
|
|
10
|
-
map.delete(oldest);
|
|
11
|
-
}
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const textEncoder = new TextEncoder();
|
|
15
|
-
const MAX_SIGNED_URL_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
16
|
-
const SCHEME_PREFIX_RE = /^[a-z][a-z0-9+\-.]*:\/\//i;
|
|
17
|
-
const toBase64Url = (bytes) => {
|
|
18
|
-
const binary = String.fromCodePoint(...bytes);
|
|
19
|
-
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
20
|
-
};
|
|
21
|
-
const fromBase64Url = (input) => {
|
|
22
|
-
const padded = input.replaceAll("-", "+").replaceAll("_", "/") + "===".slice((input.length + 3) % 4);
|
|
23
|
-
const binary = atob(padded);
|
|
24
|
-
const bytes = new Uint8Array(binary.length);
|
|
25
|
-
for (let index = 0; index < binary.length; index += 1) {
|
|
26
|
-
bytes[index] = binary.codePointAt(index) ?? 0;
|
|
27
|
-
}
|
|
28
|
-
return bytes;
|
|
29
|
-
};
|
|
30
|
-
const KEY_CACHE_MAX = 64;
|
|
31
|
-
const keyCache = /* @__PURE__ */ new Map();
|
|
32
|
-
const importHmacKey = async (secret) => {
|
|
33
|
-
const cached = keyCache.get(secret);
|
|
34
|
-
if (cached) {
|
|
35
|
-
return cached;
|
|
36
|
-
}
|
|
37
|
-
evictOldestEntry(keyCache, KEY_CACHE_MAX);
|
|
38
|
-
const keyPromise = crypto.subtle.importKey("raw", textEncoder.encode(secret), { hash: "SHA-256", name: "HMAC" }, false, ["sign", "verify"]);
|
|
39
|
-
keyCache.set(secret, keyPromise);
|
|
40
|
-
return keyPromise;
|
|
41
|
-
};
|
|
42
|
-
const extractHost = (input) => {
|
|
43
|
-
try {
|
|
44
|
-
return new URL(input).host;
|
|
45
|
-
} catch {
|
|
46
|
-
const noScheme = input.replace(SCHEME_PREFIX_RE, "");
|
|
47
|
-
return noScheme.split("/")[0] ?? "";
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
const signCanonical = async (secret, canonical) => {
|
|
51
|
-
const cryptoKey = await importHmacKey(secret);
|
|
52
|
-
const signature = await crypto.subtle.sign("HMAC", cryptoKey, textEncoder.encode(canonical));
|
|
53
|
-
return toBase64Url(new Uint8Array(signature));
|
|
54
|
-
};
|
|
55
|
-
const verifyCanonical = async (secret, canonical, sigBytes) => {
|
|
56
|
-
const cryptoKey = await importHmacKey(secret);
|
|
57
|
-
return crypto.subtle.verify("HMAC", cryptoKey, sigBytes, textEncoder.encode(canonical));
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const LEADING_SLASH_RE = /^\//;
|
|
61
|
-
const canonicalize = (method, host, key, exp, contentType) => {
|
|
62
|
-
const base = `${method}
|
|
63
|
-
${host.toLowerCase()}
|
|
64
|
-
${key}
|
|
65
|
-
${String(exp)}`;
|
|
66
|
-
return contentType === void 0 ? base : `${base}
|
|
67
|
-
${contentType}`;
|
|
68
|
-
};
|
|
69
|
-
const buildSignedUrl = async (args) => {
|
|
70
|
-
const method = args.method ?? "GET";
|
|
71
|
-
const expiresInSeconds = args.expiresInSeconds ?? 60 * 60;
|
|
72
|
-
if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) {
|
|
73
|
-
throw new LunoraError("VALIDATION_ERROR", "@lunora/storage: expiresInSeconds must be a positive finite number");
|
|
74
|
-
}
|
|
75
|
-
if (expiresInSeconds > MAX_SIGNED_URL_TTL_SECONDS) {
|
|
76
|
-
throw new LunoraError("VALIDATION_ERROR", `@lunora/storage: expiresInSeconds must not exceed ${String(MAX_SIGNED_URL_TTL_SECONDS)} (7 days)`);
|
|
77
|
-
}
|
|
78
|
-
const contentType = method === "PUT" ? args.contentType : void 0;
|
|
79
|
-
const exp = Math.floor(Date.now() / 1e3) + expiresInSeconds;
|
|
80
|
-
const host = extractHost(args.baseUrl);
|
|
81
|
-
const sig = await signCanonical(args.secret, canonicalize(method, host, args.key, exp, contentType));
|
|
82
|
-
const base = trimTrailingSlashes(args.baseUrl);
|
|
83
|
-
const safeKey = args.key.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
84
|
-
const ctParameter = contentType === void 0 ? "" : `&ct=${encodeURIComponent(contentType)}`;
|
|
85
|
-
return `${base}/${safeKey}?exp=${String(exp)}&method=${method}&sig=${sig}${ctParameter}`;
|
|
86
|
-
};
|
|
87
|
-
const verifySignedUrl = async (input, secret, options) => {
|
|
88
|
-
let url;
|
|
89
|
-
try {
|
|
90
|
-
url = input instanceof URL ? input : new URL(input);
|
|
91
|
-
} catch {
|
|
92
|
-
return { reason: "malformed", valid: false };
|
|
93
|
-
}
|
|
94
|
-
const expRaw = url.searchParams.get("exp");
|
|
95
|
-
const exp = expRaw === null ? Number.NaN : Number(expRaw);
|
|
96
|
-
const sig = url.searchParams.get("sig");
|
|
97
|
-
const method = url.searchParams.get("method") ?? "GET";
|
|
98
|
-
const contentType = url.searchParams.get("ct") ?? void 0;
|
|
99
|
-
if (!sig || !Number.isInteger(exp)) {
|
|
100
|
-
return { reason: "malformed", valid: false };
|
|
101
|
-
}
|
|
102
|
-
if (exp < Math.floor(Date.now() / 1e3)) {
|
|
103
|
-
return { reason: "expired", valid: false };
|
|
104
|
-
}
|
|
105
|
-
if (method !== "GET" && method !== "PUT") {
|
|
106
|
-
return { reason: "malformed", valid: false };
|
|
107
|
-
}
|
|
108
|
-
let key;
|
|
109
|
-
let sigBytes;
|
|
110
|
-
try {
|
|
111
|
-
key = url.pathname.replace(LEADING_SLASH_RE, "").split("/").map((segment) => decodeURIComponent(segment)).join("/");
|
|
112
|
-
sigBytes = fromBase64Url(sig);
|
|
113
|
-
} catch {
|
|
114
|
-
return { reason: "malformed", valid: false };
|
|
115
|
-
}
|
|
116
|
-
const host = options?.expectedHost === void 0 ? url.host : extractHost(options.expectedHost);
|
|
117
|
-
const valid = await verifyCanonical(secret, canonicalize(method, host, key, exp, contentType), sigBytes);
|
|
118
|
-
if (!valid) {
|
|
119
|
-
return { reason: "bad_signature", valid: false };
|
|
120
|
-
}
|
|
121
|
-
return { contentType, key, method, valid: true };
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
export { buildSignedUrl, verifySignedUrl };
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
|
|
3
|
-
const createBucketStorage = (buckets, options = {}) => {
|
|
4
|
-
const names = Object.keys(buckets);
|
|
5
|
-
const [firstName] = names;
|
|
6
|
-
if (firstName === void 0) {
|
|
7
|
-
throw new LunoraError("INTERNAL", "@lunora/storage: createBucketStorage requires at least one bucket");
|
|
8
|
-
}
|
|
9
|
-
if (options.default !== void 0 && !buckets[options.default]) {
|
|
10
|
-
throw new LunoraError("INTERNAL", `@lunora/storage: default bucket "${options.default}" is not in the bucket map (have: ${names.join(", ")})`);
|
|
11
|
-
}
|
|
12
|
-
const defaultTag = options.default ?? "default";
|
|
13
|
-
const defaultBinding = buckets[defaultTag] ?? buckets[firstName];
|
|
14
|
-
if (defaultBinding === void 0) {
|
|
15
|
-
throw new LunoraError("INTERNAL", `@lunora/storage: default bucket "${defaultTag}" is not in the bucket map (have: ${names.join(", ")})`);
|
|
16
|
-
}
|
|
17
|
-
const addressable = [.../* @__PURE__ */ new Set([defaultTag, ...names])];
|
|
18
|
-
const make = (name) => {
|
|
19
|
-
const target = name === defaultTag ? defaultBinding : buckets[name];
|
|
20
|
-
if (!target) {
|
|
21
|
-
throw new LunoraError("INTERNAL", `@lunora/storage: no bucket registered for "${name}". Known buckets: ${addressable.join(", ")}`);
|
|
22
|
-
}
|
|
23
|
-
return { ...target, bucket: (next) => make(next), bucketName: name };
|
|
24
|
-
};
|
|
25
|
-
return make(defaultTag);
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
export { createBucketStorage };
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { Rest, Multipart, Tus } from '@visulima/storage/handler/http/fetch';
|
|
2
|
-
import { AwsLightStorage } from '@visulima/storage/provider/aws-light';
|
|
3
|
-
|
|
4
|
-
const TUS_RESUMABLE = "1.0.0";
|
|
5
|
-
const denyResponse = (protocol) => {
|
|
6
|
-
const headers = { "content-type": "application/json" };
|
|
7
|
-
if (protocol === "tus") {
|
|
8
|
-
headers["Tus-Resumable"] = TUS_RESUMABLE;
|
|
9
|
-
}
|
|
10
|
-
return Response.json({ error: { code: "FORBIDDEN", message: "Upload denied by authorization policy", name: "ForbiddenError" } }, { headers, status: 403 });
|
|
11
|
-
};
|
|
12
|
-
const instantiateHandler = (protocol, handlerOptions) => {
|
|
13
|
-
if (protocol === "chunked-rest") {
|
|
14
|
-
return new Rest(handlerOptions);
|
|
15
|
-
}
|
|
16
|
-
if (protocol === "multipart") {
|
|
17
|
-
return new Multipart(handlerOptions);
|
|
18
|
-
}
|
|
19
|
-
return new Tus(handlerOptions);
|
|
20
|
-
};
|
|
21
|
-
const createUploadHandler = (options) => {
|
|
22
|
-
const protocol = options.protocol ?? "tus";
|
|
23
|
-
const handlerOptions = {
|
|
24
|
-
storage: options.storage,
|
|
25
|
-
...options.maxFileSize === void 0 ? {} : { maxFileSize: options.maxFileSize }
|
|
26
|
-
};
|
|
27
|
-
const handler = instantiateHandler(protocol, handlerOptions);
|
|
28
|
-
const { authorize } = options;
|
|
29
|
-
const fetch = async (request) => {
|
|
30
|
-
if (authorize !== void 0) {
|
|
31
|
-
try {
|
|
32
|
-
const allowed = await authorize({ method: request.method, protocol, request, url: new URL(request.url) });
|
|
33
|
-
if (!allowed) {
|
|
34
|
-
return denyResponse(protocol);
|
|
35
|
-
}
|
|
36
|
-
} catch {
|
|
37
|
-
return denyResponse(protocol);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
return handler.fetch(request);
|
|
41
|
-
};
|
|
42
|
-
return { fetch, protocol };
|
|
43
|
-
};
|
|
44
|
-
const createR2UploadStorage = (options) => new AwsLightStorage({
|
|
45
|
-
accessKeyId: options.accessKeyId,
|
|
46
|
-
bucket: options.bucket,
|
|
47
|
-
endpoint: options.endpoint ?? `https://${options.accountId}.r2.cloudflarestorage.com`,
|
|
48
|
-
path: options.path ?? "/",
|
|
49
|
-
region: "auto",
|
|
50
|
-
secretAccessKey: options.secretAccessKey,
|
|
51
|
-
...options.partSize === void 0 ? {} : { partSize: options.partSize }
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
export { createR2UploadStorage, createUploadHandler };
|