@lunora/storage 1.0.0-alpha.16 → 1.0.0-alpha.160
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 +127 -37
- package/dist/index.d.ts +127 -37
- package/dist/index.mjs +1 -1
- package/dist/packem_shared/DEFAULT_MAX_UPLOAD_BYTES-BOaXqwP5.mjs +1 -0
- package/dist/packem_shared/R2_PART_SIZE-D1p32KXe.mjs +1 -0
- package/dist/packem_shared/buildPresignedUrl-GEo1eFpq.mjs +5 -0
- package/dist/packem_shared/buildSignedUrl-Bg2beHZA.mjs +6 -0
- package/dist/packem_shared/createBucketStorage-AqIMq050.mjs +1 -0
- package/dist/packem_shared/createR2BindingUploadStorage-A9OJQ4j_.mjs +1 -0
- package/dist/packem_shared/createR2UploadStorage-DGB1xJiD.mjs +1 -0
- package/dist/packem_shared/createStorage-DzyBEGJr.mjs +1 -0
- package/dist/packem_shared/internal-B047mSU9.mjs +1 -0
- package/dist/packem_shared/r2-upload-part-writer-M7dEI00-.mjs +1 -0
- package/dist/upload.d.mts +217 -20
- package/dist/upload.d.ts +217 -20
- package/dist/upload.mjs +1 -1
- package/package.json +4 -4
- package/dist/packem_shared/DEFAULT_MAX_UPLOAD_BYTES-CeL_tQBR.mjs +0 -1
- package/dist/packem_shared/buildPresignedUrl-TCv-5TAT.mjs +0 -4
- package/dist/packem_shared/buildSignedUrl-CT6IDyA5.mjs +0 -1
- package/dist/packem_shared/createBucketStorage-DAip6cEw.mjs +0 -1
- package/dist/packem_shared/createStorage-BDKKnFcV.mjs +0 -1
- package/dist/packem_shared/internal-DpjlEJPE.mjs +0 -1
- package/dist/packem_shared/signed-url-4m6WhCR1.mjs +0 -5
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
37
|
-
R2-backed file storage for Lunora. Wraps a Cloudflare `R2Bucket` binding with a typed API (`upload`/`store`, `download`, `delete`, `list`, `getMetadata`, multipart), worker-signed URLs for app-gated access, and native S3 presigned URLs for direct-to-R2 transfer.
|
|
37
|
+
R2-backed file storage for Lunora. Wraps a Cloudflare `R2Bucket` binding with a typed API (`upload`/`store`, `download`, `head`, `delete`, `list`, `getMetadata`, multipart), worker-signed URLs for app-gated access, and native S3 presigned URLs for direct-to-R2 transfer.
|
|
38
38
|
|
|
39
39
|
Part of the [Lunora](https://github.com/anolilab/lunora) framework — a type-safe, real-time backend on Cloudflare Workers + Durable Objects with a Vite-first DX.
|
|
40
40
|
|
|
@@ -75,13 +75,15 @@ export default defineApp<Env>()
|
|
|
75
75
|
import { action, v } from "@/lunora/_generated/server";
|
|
76
76
|
|
|
77
77
|
// Minting an upload URL is a write capability, so this is an `action`.
|
|
78
|
-
// Queries and mutations
|
|
79
|
-
// (generateUploadUrl/store/delete/
|
|
78
|
+
// Queries and mutations are TYPED with a read-only `ctx.storage`; the full
|
|
79
|
+
// surface (generateUploadUrl/store/delete/getPresignedUrl) is action-only.
|
|
80
|
+
// Multipart and `list` are on a `createStorage` instance, not on any ctx.
|
|
80
81
|
export const uploadAvatar = action.input({ contentType: v.string() }).action(async ({ args, ctx }) => {
|
|
81
82
|
const key = `avatars/${ctx.auth.userId ?? "anonymous"}/profile`;
|
|
82
83
|
|
|
83
|
-
// Short-lived signed PUT URL — the client uploads
|
|
84
|
-
//
|
|
84
|
+
// Short-lived signed PUT URL — the client uploads to your Worker route,
|
|
85
|
+
// which verifies the signature and writes to R2, with the Content-Type
|
|
86
|
+
// pinned into that signature.
|
|
85
87
|
const url = await ctx.storage.generateUploadUrl(key, { contentType: args.contentType, expiresInSeconds: 60 });
|
|
86
88
|
|
|
87
89
|
return { key, url };
|
|
@@ -95,6 +97,12 @@ import { createStorage } from "@lunora/storage";
|
|
|
95
97
|
|
|
96
98
|
const storage = createStorage({
|
|
97
99
|
bucket: env.FILES,
|
|
100
|
+
// Required. The name this bucket is registered under, bound into every signed
|
|
101
|
+
// URL's HMAC (and mirrored as `&bucket=`), so a URL minted for one bucket
|
|
102
|
+
// cannot be replayed against another sharing the signing secret. `"default"`
|
|
103
|
+
// for a single-bucket app; the registered name for anything reached through
|
|
104
|
+
// `createBucketStorage`.
|
|
105
|
+
bucketName: "default",
|
|
98
106
|
publicBaseUrl: "https://cdn.acme.test",
|
|
99
107
|
signingSecret: env.STORAGE_SECRET,
|
|
100
108
|
});
|
|
@@ -104,12 +112,12 @@ await storage.upload("uploads/avatar.png", bytes, { contentType: "image/png", ma
|
|
|
104
112
|
const url = await storage.getSignedUrl("uploads/avatar.png", { expiresInSeconds: 600 });
|
|
105
113
|
```
|
|
106
114
|
|
|
107
|
-
> This README covers the basics. For the full API, options, and guides, see the **[documentation](https://lunora.sh/docs/
|
|
115
|
+
> This README covers the basics. For the full API, options, and guides, see the **[documentation](https://lunora.sh/docs/packages/storage)**.
|
|
108
116
|
|
|
109
117
|
## Related
|
|
110
118
|
|
|
111
119
|
- [`@lunora/server`](https://www.npmjs.com/package/@lunora/server) — call storage from queries, mutations, and actions.
|
|
112
|
-
- [`@lunora/runtime`](https://www.npmjs.com/package/@lunora/runtime) — the Worker runtime that
|
|
120
|
+
- [`@lunora/runtime`](https://www.npmjs.com/package/@lunora/runtime) — the Worker runtime your signed-URL route sits in front of. It ships no public storage route of its own: the route that verifies a signed URL and moves the bytes is yours to add (see the `storage` registry item).
|
|
113
121
|
- [`@lunora/d1`](https://www.npmjs.com/package/@lunora/d1) — store object metadata alongside your data.
|
|
114
122
|
|
|
115
123
|
## Supported Node.js Versions
|
package/dist/index.d.mts
CHANGED
|
@@ -20,13 +20,27 @@ interface R2S3Credentials {
|
|
|
20
20
|
}
|
|
21
21
|
/** Options for {@link Storage.getPresignedUrl}. */
|
|
22
22
|
interface PresignedUrlOptions {
|
|
23
|
-
/** Seconds the URL stays valid
|
|
23
|
+
/** Seconds the URL stays valid: 1 to 604800 (7 days); an out-of-range value throws. Default 900. */
|
|
24
24
|
expiresInSeconds?: number;
|
|
25
25
|
/** HTTP method the URL authorizes. Default `GET`. */
|
|
26
26
|
method?: "GET" | "PUT";
|
|
27
27
|
}
|
|
28
28
|
interface LunoraStorageOptions {
|
|
29
29
|
bucket: R2BucketLike;
|
|
30
|
+
/**
|
|
31
|
+
* The name this bucket is registered under — the same string a
|
|
32
|
+
* `defineStorageRule({ bucket })` rule and the generated `StorageBucketName`
|
|
33
|
+
* union use. Bound into every signed URL's HMAC and mirrored on it as
|
|
34
|
+
* `&bucket=`, so a URL minted for one bucket can't be replayed against
|
|
35
|
+
* another sharing the signing secret, and the serving route can resolve
|
|
36
|
+
* which bucket to read.
|
|
37
|
+
*
|
|
38
|
+
* Required, and deliberately without a default: a defaulted name is how
|
|
39
|
+
* every bucket ended up signing as `"default"` and cross-verifying against
|
|
40
|
+
* each other. Pass `"default"` for a single-bucket app's `ctx.storage`, and
|
|
41
|
+
* the registered name for any bucket reached through `createBucketStorage`.
|
|
42
|
+
*/
|
|
43
|
+
bucketName: string;
|
|
30
44
|
/** Public base URL used by `getSignedUrl()`. Required for signed URLs. */
|
|
31
45
|
publicBaseUrl?: string;
|
|
32
46
|
/**
|
|
@@ -45,26 +59,50 @@ interface UploadOptions {
|
|
|
45
59
|
customMetadata?: Record<string, string>;
|
|
46
60
|
/**
|
|
47
61
|
* Maximum body size in bytes. For `ArrayBuffer`/`Blob` sources the length is
|
|
48
|
-
* known up front and rejected before the upload starts.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
62
|
+
* known up front and rejected before the upload starts.
|
|
63
|
+
*
|
|
64
|
+
* A `ReadableStream` has no length to check, and R2 refuses any stream whose
|
|
65
|
+
* length it cannot read — so a capped stream is READ INTO MEMORY under the
|
|
66
|
+
* cap and uploaded as a sized body. Nothing reaches the bucket if the body
|
|
67
|
+
* crosses the limit.
|
|
68
|
+
*
|
|
69
|
+
* `maxSize` is therefore also the memory ceiling for a streamed upload, and
|
|
70
|
+
* the isolate's ~128 MB is shared by every concurrent request — so on the
|
|
71
|
+
* stream path `maxSize` is itself capped at 16 MiB and rejected above it.
|
|
72
|
+
* For objects larger than that use `createMultipartUpload` /
|
|
73
|
+
* `createUploadHandler`, which upload without ever holding the whole object.
|
|
74
|
+
* Must be a finite, non-negative number; anything else (`Number(undefined)`
|
|
75
|
+
* is the usual source) is rejected as a `VALIDATION_ERROR`.
|
|
53
76
|
*/
|
|
54
77
|
maxSize?: number;
|
|
78
|
+
/**
|
|
79
|
+
* SHA-256 of the body (hex or a 32-byte buffer), recorded with the object.
|
|
80
|
+
*
|
|
81
|
+
* R2 only reports a checksum it was given: without this, `list()`/`head()`
|
|
82
|
+
* return no `sha256` and any later integrity check degrades to comparing
|
|
83
|
+
* sizes. R2 also verifies the digest itself on write, so supplying it turns
|
|
84
|
+
* the upload into a checked one.
|
|
85
|
+
*/
|
|
86
|
+
sha256?: ArrayBuffer | string;
|
|
55
87
|
}
|
|
56
88
|
interface ListOptions {
|
|
57
89
|
cursor?: string;
|
|
58
90
|
/** R2 list delimiter — when set, common prefixes group instead of listing. */
|
|
59
91
|
delimiter?: string;
|
|
60
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Defaults to 100, capped at 1000 (R2 limit). A ceiling, not a promise: R2
|
|
94
|
+
* may return fewer per page to fit the entry metadata.
|
|
95
|
+
*
|
|
96
|
+
* Must be a positive integer when given — `0`, `12.5` and `NaN` throw rather
|
|
97
|
+
* than being coerced into a page size the caller never asked for.
|
|
98
|
+
*/
|
|
61
99
|
limit?: number;
|
|
62
100
|
}
|
|
63
101
|
interface SignedUrlOptions {
|
|
64
102
|
/**
|
|
65
103
|
* Pin the `Content-Type` an uploader must send on a `method: "PUT"` URL.
|
|
66
104
|
* Baked into the HMAC canonical so the signature only authorizes a PUT with
|
|
67
|
-
* exactly this content-type; mirrored on the URL as `&
|
|
105
|
+
* exactly this content-type; mirrored on the URL as `&ct=...`. Ignored for
|
|
68
106
|
* `GET` URLs (a download has no request body content-type to pin).
|
|
69
107
|
*/
|
|
70
108
|
contentType?: string;
|
|
@@ -91,6 +129,17 @@ interface ObjectMetadata {
|
|
|
91
129
|
uploaded?: number;
|
|
92
130
|
}
|
|
93
131
|
interface Storage {
|
|
132
|
+
/**
|
|
133
|
+
* The bucket name this accessor operates under — the same value
|
|
134
|
+
* {@link Storage.getSignedUrl} puts into the HMAC canonical.
|
|
135
|
+
*
|
|
136
|
+
* Exposed so everything downstream agrees on one name: `asBucketStorage`
|
|
137
|
+
* tags a single-bucket storage with it, and `storageRules(...)` matches
|
|
138
|
+
* `(bucket, operation)` rules against that tag. Without it a
|
|
139
|
+
* `createStorage({ bucketName: "avatars" })` signed URLs as `avatars` while
|
|
140
|
+
* the rules engine only ever saw `"default"`.
|
|
141
|
+
*/
|
|
142
|
+
readonly bucketName: string;
|
|
94
143
|
/**
|
|
95
144
|
* Begin a native R2 **multipart upload** for very large objects — upload
|
|
96
145
|
* parts (each uniform in size except the last), then `complete` with the
|
|
@@ -122,10 +171,10 @@ interface Storage {
|
|
|
122
171
|
}) => Promise<string>;
|
|
123
172
|
/**
|
|
124
173
|
* Read a stored object's metadata (size, content-type, sha256, upload time,
|
|
125
|
-
* custom metadata) without fetching its body
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* `ctx.storage.getMetadata`.
|
|
174
|
+
* custom metadata) without fetching its body, as a flat serializable shape.
|
|
175
|
+
* Returns `null` when the object is absent. A projection of
|
|
176
|
+
* {@link Storage.head}, so it makes the same single body-free read. Mirrors
|
|
177
|
+
* Convex's `ctx.storage.getMetadata`.
|
|
129
178
|
*/
|
|
130
179
|
getMetadata: (key: string) => Promise<ObjectMetadata | null>;
|
|
131
180
|
/**
|
|
@@ -138,8 +187,32 @@ interface Storage {
|
|
|
138
187
|
getPresignedUrl: (key: string, options?: PresignedUrlOptions) => Promise<string>;
|
|
139
188
|
getSignedUrl: (key: string, options?: SignedUrlOptions) => Promise<string>;
|
|
140
189
|
getUrl: (key: string) => string;
|
|
190
|
+
/**
|
|
191
|
+
* Read an object's R2 metadata WITHOUT its body — `size` (the full object
|
|
192
|
+
* size), `etag`, `httpMetadata`, `checksums`, plus the `sha256`/`sha256Base64`
|
|
193
|
+
* projection `download()` adds. Returns `null` when the object is absent.
|
|
194
|
+
*
|
|
195
|
+
* Backed by an R2 HEAD when the binding exposes one, falling back to a
|
|
196
|
+
* 0-length ranged `get()` otherwise. Prefer this over `download()` whenever
|
|
197
|
+
* only the metadata is wanted — notably to resolve a `Range` header, where a
|
|
198
|
+
* plain `download()` starts a full-object body transfer that is then thrown
|
|
199
|
+
* away. {@link Storage.getMetadata} is the flat, serializable projection of
|
|
200
|
+
* the same read.
|
|
201
|
+
*/
|
|
202
|
+
head: (key: string) => Promise<R2ObjectLike | null>;
|
|
203
|
+
/**
|
|
204
|
+
* List objects under `prefix`. With `options.delimiter` set, keys sharing a
|
|
205
|
+
* segment are rolled up into `delimitedPrefixes` (the "folders") and are NOT
|
|
206
|
+
* in `objects` — a folder browser needs both, so a listing whose `objects` is
|
|
207
|
+
* empty is not an empty directory.
|
|
208
|
+
*
|
|
209
|
+
* A page may hold FEWER objects than `options.limit`: R2 shrinks a page to
|
|
210
|
+
* fit the per-entry metadata this call asks for. Paginate on `truncated` /
|
|
211
|
+
* `cursor`, never on `objects.length === limit`.
|
|
212
|
+
*/
|
|
141
213
|
list: (prefix?: string, options?: ListOptions) => Promise<{
|
|
142
214
|
cursor?: string;
|
|
215
|
+
delimitedPrefixes?: string[];
|
|
143
216
|
objects: R2ObjectLike[];
|
|
144
217
|
truncated?: boolean;
|
|
145
218
|
}>;
|
|
@@ -154,12 +227,20 @@ interface Storage {
|
|
|
154
227
|
* alias for {@link Storage.upload} — it accepts the same {@link UploadOptions}
|
|
155
228
|
* so the `maxSize` / `allowedContentTypes` guards aren't lost behind the alias.
|
|
156
229
|
*/
|
|
157
|
-
store: (key: string, body: ReadableStream | ArrayBuffer | Blob, options?: UploadOptions) => Promise<{
|
|
230
|
+
store: (key: string, body: ReadableStream | ArrayBuffer | ArrayBufferView | Blob | string, options?: UploadOptions) => Promise<{
|
|
158
231
|
etag: string;
|
|
159
232
|
httpEtag: string;
|
|
160
233
|
key: string;
|
|
161
234
|
}>;
|
|
162
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Upload `body` to `key`, returning the stored key + etag.
|
|
237
|
+
*
|
|
238
|
+
* The accepted shapes mirror what R2's `put` stores bytes from — a view
|
|
239
|
+
* (`Uint8Array`) and a `string` included. They are listed here because
|
|
240
|
+
* `maxSize` measures every one of them; a shape the cap cannot measure is
|
|
241
|
+
* refused rather than uploaded uncapped.
|
|
242
|
+
*/
|
|
243
|
+
upload: (key: string, body: ReadableStream | ArrayBuffer | ArrayBufferView | Blob | string, options?: UploadOptions) => Promise<{
|
|
163
244
|
etag: string;
|
|
164
245
|
httpEtag: string;
|
|
165
246
|
key: string;
|
|
@@ -185,36 +266,32 @@ interface BucketStorage extends Storage {
|
|
|
185
266
|
*
|
|
186
267
|
* ```ts
|
|
187
268
|
* storage: (env) => createBucketStorage({
|
|
188
|
-
* default: createStorage({ bucket: env.FILES }),
|
|
189
|
-
* avatars: createStorage({ bucket: env.AVATARS }),
|
|
269
|
+
* default: createStorage({ bucket: env.FILES, bucketName: "default" }),
|
|
270
|
+
* avatars: createStorage({ bucket: env.AVATARS, bucketName: "avatars" }),
|
|
190
271
|
* }),
|
|
191
272
|
* // → ctx.storage.download(key) // default bucket
|
|
192
273
|
* // → ctx.storage.bucket("avatars").store() // the avatars bucket
|
|
193
274
|
* ```
|
|
194
275
|
*
|
|
195
|
-
* The bare accessor is tagged
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
276
|
+
* The bare accessor is tagged with the name of the binding it actually
|
|
277
|
+
* delegates to: `options.default` when given, else `"default"` when that key
|
|
278
|
+
* exists (the canonical name a `defineStorageRule({ bucket: "default" })` rule
|
|
279
|
+
* and the generated `StorageBucketName` union use), else the first registered
|
|
280
|
+
* bucket. Tag and binding must agree or a `{ bucket: "avatars" }` rule would
|
|
281
|
+
* gate `bucket("avatars").download()` and not the identical bare
|
|
282
|
+
* `ctx.storage.download()` reaching the same R2 bucket. Named buckets are
|
|
283
|
+
* reached with `bucket(name)`.
|
|
201
284
|
*/
|
|
202
285
|
declare const createBucketStorage: (buckets: Record<string, Storage>, options?: {
|
|
203
286
|
default?: string;
|
|
204
287
|
}) => BucketStorage;
|
|
205
|
-
/**
|
|
206
|
-
* Compose a per-tenant key from a scope prefix and a caller-supplied key.
|
|
207
|
-
* Both halves are validated — the prefix may not contain `..` or NUL either,
|
|
208
|
-
* and the resulting key must stay under R2's length ceiling. Recommended for
|
|
209
|
-
* any multi-tenant deployment so client-supplied keys can't address peer data.
|
|
210
|
-
*/
|
|
211
288
|
declare const scopeKey: (prefix: string, key: string) => string;
|
|
212
289
|
declare const createStorage: (options: LunoraStorageOptions) => Storage;
|
|
213
290
|
/** Parameters accepted by {@link buildPresignedUrl}. */
|
|
214
291
|
interface PresignedUrlParams {
|
|
215
292
|
/** R2 S3 API credentials + bucket/account. */
|
|
216
293
|
credentials: R2S3Credentials;
|
|
217
|
-
/** Seconds the URL stays valid
|
|
294
|
+
/** Seconds the URL stays valid: 1 to 604800 (7 days); an out-of-range value throws. Default 900. */
|
|
218
295
|
expiresInSeconds?: number;
|
|
219
296
|
/** Object key (path-style; not URL-encoded by the caller). */
|
|
220
297
|
key: string;
|
|
@@ -235,20 +312,33 @@ declare const buildPresignedUrl: (parameters: PresignedUrlParams) => Promise<str
|
|
|
235
312
|
* string carrying `exp` (unix seconds), `method` (`GET` or `PUT`) and `sig`
|
|
236
313
|
* (a base64url HMAC).
|
|
237
314
|
*
|
|
238
|
-
* The HMAC canonical includes the URL host so a signature
|
|
239
|
-
* cannot be replayed against another
|
|
240
|
-
*
|
|
241
|
-
*
|
|
315
|
+
* The HMAC canonical includes the URL host AND the `bucketName`, so a signature
|
|
316
|
+
* minted for one bucket cannot be replayed against another bucket (all buckets
|
|
317
|
+
* of one `.storage()` declaration share a base URL and signing secret) or
|
|
318
|
+
* another host. Even so, the signing secret MUST NOT be shared across
|
|
319
|
+
* tenants — this binding narrows replay surface but is not a substitute for
|
|
320
|
+
* per-tenant key isolation.
|
|
242
321
|
*
|
|
243
|
-
* The Worker handling
|
|
244
|
-
*
|
|
322
|
+
* The Worker route handling the signed download/upload (mounted at
|
|
323
|
+
* `publicBaseUrl`'s origin, e.g. `GET /:key`) should call
|
|
324
|
+
* {@link verifySignedUrl} to validate the signature + expiry before streaming
|
|
325
|
+
* the R2 body. `baseUrl` must be a bare origin (no path) — see the module
|
|
326
|
+
* docstring.
|
|
245
327
|
*/
|
|
246
|
-
declare const buildSignedUrl: (args:
|
|
328
|
+
declare const buildSignedUrl: (args: {
|
|
247
329
|
baseUrl: string;
|
|
330
|
+
/** The bucket the URL addresses — bound into the HMAC and mirrored as `&bucket=`. */
|
|
331
|
+
bucketName: string;
|
|
248
332
|
key: string;
|
|
249
333
|
secret: string;
|
|
250
|
-
}) => Promise<string>;
|
|
334
|
+
} & SignedUrlOptions) => Promise<string>;
|
|
251
335
|
interface VerifyResult {
|
|
336
|
+
/**
|
|
337
|
+
* The bucket the URL was minted for. The serving route MUST resolve its R2
|
|
338
|
+
* binding from this (never from a caller-supplied bucket), since one signing
|
|
339
|
+
* secret covers every bucket of a `.storage()` declaration.
|
|
340
|
+
*/
|
|
341
|
+
bucketName?: string;
|
|
252
342
|
/** The pinned upload `Content-Type` carried by a PUT URL, when present. */
|
|
253
343
|
contentType?: string;
|
|
254
344
|
key?: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -20,13 +20,27 @@ interface R2S3Credentials {
|
|
|
20
20
|
}
|
|
21
21
|
/** Options for {@link Storage.getPresignedUrl}. */
|
|
22
22
|
interface PresignedUrlOptions {
|
|
23
|
-
/** Seconds the URL stays valid
|
|
23
|
+
/** Seconds the URL stays valid: 1 to 604800 (7 days); an out-of-range value throws. Default 900. */
|
|
24
24
|
expiresInSeconds?: number;
|
|
25
25
|
/** HTTP method the URL authorizes. Default `GET`. */
|
|
26
26
|
method?: "GET" | "PUT";
|
|
27
27
|
}
|
|
28
28
|
interface LunoraStorageOptions {
|
|
29
29
|
bucket: R2BucketLike;
|
|
30
|
+
/**
|
|
31
|
+
* The name this bucket is registered under — the same string a
|
|
32
|
+
* `defineStorageRule({ bucket })` rule and the generated `StorageBucketName`
|
|
33
|
+
* union use. Bound into every signed URL's HMAC and mirrored on it as
|
|
34
|
+
* `&bucket=`, so a URL minted for one bucket can't be replayed against
|
|
35
|
+
* another sharing the signing secret, and the serving route can resolve
|
|
36
|
+
* which bucket to read.
|
|
37
|
+
*
|
|
38
|
+
* Required, and deliberately without a default: a defaulted name is how
|
|
39
|
+
* every bucket ended up signing as `"default"` and cross-verifying against
|
|
40
|
+
* each other. Pass `"default"` for a single-bucket app's `ctx.storage`, and
|
|
41
|
+
* the registered name for any bucket reached through `createBucketStorage`.
|
|
42
|
+
*/
|
|
43
|
+
bucketName: string;
|
|
30
44
|
/** Public base URL used by `getSignedUrl()`. Required for signed URLs. */
|
|
31
45
|
publicBaseUrl?: string;
|
|
32
46
|
/**
|
|
@@ -45,26 +59,50 @@ interface UploadOptions {
|
|
|
45
59
|
customMetadata?: Record<string, string>;
|
|
46
60
|
/**
|
|
47
61
|
* Maximum body size in bytes. For `ArrayBuffer`/`Blob` sources the length is
|
|
48
|
-
* known up front and rejected before the upload starts.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
62
|
+
* known up front and rejected before the upload starts.
|
|
63
|
+
*
|
|
64
|
+
* A `ReadableStream` has no length to check, and R2 refuses any stream whose
|
|
65
|
+
* length it cannot read — so a capped stream is READ INTO MEMORY under the
|
|
66
|
+
* cap and uploaded as a sized body. Nothing reaches the bucket if the body
|
|
67
|
+
* crosses the limit.
|
|
68
|
+
*
|
|
69
|
+
* `maxSize` is therefore also the memory ceiling for a streamed upload, and
|
|
70
|
+
* the isolate's ~128 MB is shared by every concurrent request — so on the
|
|
71
|
+
* stream path `maxSize` is itself capped at 16 MiB and rejected above it.
|
|
72
|
+
* For objects larger than that use `createMultipartUpload` /
|
|
73
|
+
* `createUploadHandler`, which upload without ever holding the whole object.
|
|
74
|
+
* Must be a finite, non-negative number; anything else (`Number(undefined)`
|
|
75
|
+
* is the usual source) is rejected as a `VALIDATION_ERROR`.
|
|
53
76
|
*/
|
|
54
77
|
maxSize?: number;
|
|
78
|
+
/**
|
|
79
|
+
* SHA-256 of the body (hex or a 32-byte buffer), recorded with the object.
|
|
80
|
+
*
|
|
81
|
+
* R2 only reports a checksum it was given: without this, `list()`/`head()`
|
|
82
|
+
* return no `sha256` and any later integrity check degrades to comparing
|
|
83
|
+
* sizes. R2 also verifies the digest itself on write, so supplying it turns
|
|
84
|
+
* the upload into a checked one.
|
|
85
|
+
*/
|
|
86
|
+
sha256?: ArrayBuffer | string;
|
|
55
87
|
}
|
|
56
88
|
interface ListOptions {
|
|
57
89
|
cursor?: string;
|
|
58
90
|
/** R2 list delimiter — when set, common prefixes group instead of listing. */
|
|
59
91
|
delimiter?: string;
|
|
60
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Defaults to 100, capped at 1000 (R2 limit). A ceiling, not a promise: R2
|
|
94
|
+
* may return fewer per page to fit the entry metadata.
|
|
95
|
+
*
|
|
96
|
+
* Must be a positive integer when given — `0`, `12.5` and `NaN` throw rather
|
|
97
|
+
* than being coerced into a page size the caller never asked for.
|
|
98
|
+
*/
|
|
61
99
|
limit?: number;
|
|
62
100
|
}
|
|
63
101
|
interface SignedUrlOptions {
|
|
64
102
|
/**
|
|
65
103
|
* Pin the `Content-Type` an uploader must send on a `method: "PUT"` URL.
|
|
66
104
|
* Baked into the HMAC canonical so the signature only authorizes a PUT with
|
|
67
|
-
* exactly this content-type; mirrored on the URL as `&
|
|
105
|
+
* exactly this content-type; mirrored on the URL as `&ct=...`. Ignored for
|
|
68
106
|
* `GET` URLs (a download has no request body content-type to pin).
|
|
69
107
|
*/
|
|
70
108
|
contentType?: string;
|
|
@@ -91,6 +129,17 @@ interface ObjectMetadata {
|
|
|
91
129
|
uploaded?: number;
|
|
92
130
|
}
|
|
93
131
|
interface Storage {
|
|
132
|
+
/**
|
|
133
|
+
* The bucket name this accessor operates under — the same value
|
|
134
|
+
* {@link Storage.getSignedUrl} puts into the HMAC canonical.
|
|
135
|
+
*
|
|
136
|
+
* Exposed so everything downstream agrees on one name: `asBucketStorage`
|
|
137
|
+
* tags a single-bucket storage with it, and `storageRules(...)` matches
|
|
138
|
+
* `(bucket, operation)` rules against that tag. Without it a
|
|
139
|
+
* `createStorage({ bucketName: "avatars" })` signed URLs as `avatars` while
|
|
140
|
+
* the rules engine only ever saw `"default"`.
|
|
141
|
+
*/
|
|
142
|
+
readonly bucketName: string;
|
|
94
143
|
/**
|
|
95
144
|
* Begin a native R2 **multipart upload** for very large objects — upload
|
|
96
145
|
* parts (each uniform in size except the last), then `complete` with the
|
|
@@ -122,10 +171,10 @@ interface Storage {
|
|
|
122
171
|
}) => Promise<string>;
|
|
123
172
|
/**
|
|
124
173
|
* Read a stored object's metadata (size, content-type, sha256, upload time,
|
|
125
|
-
* custom metadata) without fetching its body
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* `ctx.storage.getMetadata`.
|
|
174
|
+
* custom metadata) without fetching its body, as a flat serializable shape.
|
|
175
|
+
* Returns `null` when the object is absent. A projection of
|
|
176
|
+
* {@link Storage.head}, so it makes the same single body-free read. Mirrors
|
|
177
|
+
* Convex's `ctx.storage.getMetadata`.
|
|
129
178
|
*/
|
|
130
179
|
getMetadata: (key: string) => Promise<ObjectMetadata | null>;
|
|
131
180
|
/**
|
|
@@ -138,8 +187,32 @@ interface Storage {
|
|
|
138
187
|
getPresignedUrl: (key: string, options?: PresignedUrlOptions) => Promise<string>;
|
|
139
188
|
getSignedUrl: (key: string, options?: SignedUrlOptions) => Promise<string>;
|
|
140
189
|
getUrl: (key: string) => string;
|
|
190
|
+
/**
|
|
191
|
+
* Read an object's R2 metadata WITHOUT its body — `size` (the full object
|
|
192
|
+
* size), `etag`, `httpMetadata`, `checksums`, plus the `sha256`/`sha256Base64`
|
|
193
|
+
* projection `download()` adds. Returns `null` when the object is absent.
|
|
194
|
+
*
|
|
195
|
+
* Backed by an R2 HEAD when the binding exposes one, falling back to a
|
|
196
|
+
* 0-length ranged `get()` otherwise. Prefer this over `download()` whenever
|
|
197
|
+
* only the metadata is wanted — notably to resolve a `Range` header, where a
|
|
198
|
+
* plain `download()` starts a full-object body transfer that is then thrown
|
|
199
|
+
* away. {@link Storage.getMetadata} is the flat, serializable projection of
|
|
200
|
+
* the same read.
|
|
201
|
+
*/
|
|
202
|
+
head: (key: string) => Promise<R2ObjectLike | null>;
|
|
203
|
+
/**
|
|
204
|
+
* List objects under `prefix`. With `options.delimiter` set, keys sharing a
|
|
205
|
+
* segment are rolled up into `delimitedPrefixes` (the "folders") and are NOT
|
|
206
|
+
* in `objects` — a folder browser needs both, so a listing whose `objects` is
|
|
207
|
+
* empty is not an empty directory.
|
|
208
|
+
*
|
|
209
|
+
* A page may hold FEWER objects than `options.limit`: R2 shrinks a page to
|
|
210
|
+
* fit the per-entry metadata this call asks for. Paginate on `truncated` /
|
|
211
|
+
* `cursor`, never on `objects.length === limit`.
|
|
212
|
+
*/
|
|
141
213
|
list: (prefix?: string, options?: ListOptions) => Promise<{
|
|
142
214
|
cursor?: string;
|
|
215
|
+
delimitedPrefixes?: string[];
|
|
143
216
|
objects: R2ObjectLike[];
|
|
144
217
|
truncated?: boolean;
|
|
145
218
|
}>;
|
|
@@ -154,12 +227,20 @@ interface Storage {
|
|
|
154
227
|
* alias for {@link Storage.upload} — it accepts the same {@link UploadOptions}
|
|
155
228
|
* so the `maxSize` / `allowedContentTypes` guards aren't lost behind the alias.
|
|
156
229
|
*/
|
|
157
|
-
store: (key: string, body: ReadableStream | ArrayBuffer | Blob, options?: UploadOptions) => Promise<{
|
|
230
|
+
store: (key: string, body: ReadableStream | ArrayBuffer | ArrayBufferView | Blob | string, options?: UploadOptions) => Promise<{
|
|
158
231
|
etag: string;
|
|
159
232
|
httpEtag: string;
|
|
160
233
|
key: string;
|
|
161
234
|
}>;
|
|
162
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Upload `body` to `key`, returning the stored key + etag.
|
|
237
|
+
*
|
|
238
|
+
* The accepted shapes mirror what R2's `put` stores bytes from — a view
|
|
239
|
+
* (`Uint8Array`) and a `string` included. They are listed here because
|
|
240
|
+
* `maxSize` measures every one of them; a shape the cap cannot measure is
|
|
241
|
+
* refused rather than uploaded uncapped.
|
|
242
|
+
*/
|
|
243
|
+
upload: (key: string, body: ReadableStream | ArrayBuffer | ArrayBufferView | Blob | string, options?: UploadOptions) => Promise<{
|
|
163
244
|
etag: string;
|
|
164
245
|
httpEtag: string;
|
|
165
246
|
key: string;
|
|
@@ -185,36 +266,32 @@ interface BucketStorage extends Storage {
|
|
|
185
266
|
*
|
|
186
267
|
* ```ts
|
|
187
268
|
* storage: (env) => createBucketStorage({
|
|
188
|
-
* default: createStorage({ bucket: env.FILES }),
|
|
189
|
-
* avatars: createStorage({ bucket: env.AVATARS }),
|
|
269
|
+
* default: createStorage({ bucket: env.FILES, bucketName: "default" }),
|
|
270
|
+
* avatars: createStorage({ bucket: env.AVATARS, bucketName: "avatars" }),
|
|
190
271
|
* }),
|
|
191
272
|
* // → ctx.storage.download(key) // default bucket
|
|
192
273
|
* // → ctx.storage.bucket("avatars").store() // the avatars bucket
|
|
193
274
|
* ```
|
|
194
275
|
*
|
|
195
|
-
* The bare accessor is tagged
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
276
|
+
* The bare accessor is tagged with the name of the binding it actually
|
|
277
|
+
* delegates to: `options.default` when given, else `"default"` when that key
|
|
278
|
+
* exists (the canonical name a `defineStorageRule({ bucket: "default" })` rule
|
|
279
|
+
* and the generated `StorageBucketName` union use), else the first registered
|
|
280
|
+
* bucket. Tag and binding must agree or a `{ bucket: "avatars" }` rule would
|
|
281
|
+
* gate `bucket("avatars").download()` and not the identical bare
|
|
282
|
+
* `ctx.storage.download()` reaching the same R2 bucket. Named buckets are
|
|
283
|
+
* reached with `bucket(name)`.
|
|
201
284
|
*/
|
|
202
285
|
declare const createBucketStorage: (buckets: Record<string, Storage>, options?: {
|
|
203
286
|
default?: string;
|
|
204
287
|
}) => BucketStorage;
|
|
205
|
-
/**
|
|
206
|
-
* Compose a per-tenant key from a scope prefix and a caller-supplied key.
|
|
207
|
-
* Both halves are validated — the prefix may not contain `..` or NUL either,
|
|
208
|
-
* and the resulting key must stay under R2's length ceiling. Recommended for
|
|
209
|
-
* any multi-tenant deployment so client-supplied keys can't address peer data.
|
|
210
|
-
*/
|
|
211
288
|
declare const scopeKey: (prefix: string, key: string) => string;
|
|
212
289
|
declare const createStorage: (options: LunoraStorageOptions) => Storage;
|
|
213
290
|
/** Parameters accepted by {@link buildPresignedUrl}. */
|
|
214
291
|
interface PresignedUrlParams {
|
|
215
292
|
/** R2 S3 API credentials + bucket/account. */
|
|
216
293
|
credentials: R2S3Credentials;
|
|
217
|
-
/** Seconds the URL stays valid
|
|
294
|
+
/** Seconds the URL stays valid: 1 to 604800 (7 days); an out-of-range value throws. Default 900. */
|
|
218
295
|
expiresInSeconds?: number;
|
|
219
296
|
/** Object key (path-style; not URL-encoded by the caller). */
|
|
220
297
|
key: string;
|
|
@@ -235,20 +312,33 @@ declare const buildPresignedUrl: (parameters: PresignedUrlParams) => Promise<str
|
|
|
235
312
|
* string carrying `exp` (unix seconds), `method` (`GET` or `PUT`) and `sig`
|
|
236
313
|
* (a base64url HMAC).
|
|
237
314
|
*
|
|
238
|
-
* The HMAC canonical includes the URL host so a signature
|
|
239
|
-
* cannot be replayed against another
|
|
240
|
-
*
|
|
241
|
-
*
|
|
315
|
+
* The HMAC canonical includes the URL host AND the `bucketName`, so a signature
|
|
316
|
+
* minted for one bucket cannot be replayed against another bucket (all buckets
|
|
317
|
+
* of one `.storage()` declaration share a base URL and signing secret) or
|
|
318
|
+
* another host. Even so, the signing secret MUST NOT be shared across
|
|
319
|
+
* tenants — this binding narrows replay surface but is not a substitute for
|
|
320
|
+
* per-tenant key isolation.
|
|
242
321
|
*
|
|
243
|
-
* The Worker handling
|
|
244
|
-
*
|
|
322
|
+
* The Worker route handling the signed download/upload (mounted at
|
|
323
|
+
* `publicBaseUrl`'s origin, e.g. `GET /:key`) should call
|
|
324
|
+
* {@link verifySignedUrl} to validate the signature + expiry before streaming
|
|
325
|
+
* the R2 body. `baseUrl` must be a bare origin (no path) — see the module
|
|
326
|
+
* docstring.
|
|
245
327
|
*/
|
|
246
|
-
declare const buildSignedUrl: (args:
|
|
328
|
+
declare const buildSignedUrl: (args: {
|
|
247
329
|
baseUrl: string;
|
|
330
|
+
/** The bucket the URL addresses — bound into the HMAC and mirrored as `&bucket=`. */
|
|
331
|
+
bucketName: string;
|
|
248
332
|
key: string;
|
|
249
333
|
secret: string;
|
|
250
|
-
}) => Promise<string>;
|
|
334
|
+
} & SignedUrlOptions) => Promise<string>;
|
|
251
335
|
interface VerifyResult {
|
|
336
|
+
/**
|
|
337
|
+
* The bucket the URL was minted for. The serving route MUST resolve its R2
|
|
338
|
+
* binding from this (never from a caller-supplied bucket), since one signing
|
|
339
|
+
* secret covers every bucket of a `.storage()` declaration.
|
|
340
|
+
*/
|
|
341
|
+
bucketName?: string;
|
|
252
342
|
/** The pinned upload `Content-Type` carried by a PUT URL, when present. */
|
|
253
343
|
contentType?: string;
|
|
254
344
|
key?: string;
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createBucketStorage as o}from"./packem_shared/createBucketStorage-
|
|
1
|
+
import{createBucketStorage as o}from"./packem_shared/createBucketStorage-AqIMq050.mjs";import{createStorage as i,scopeKey as d}from"./packem_shared/createStorage-DzyBEGJr.mjs";import{buildPresignedUrl as g}from"./packem_shared/buildPresignedUrl-GEo1eFpq.mjs";import{buildSignedUrl as p,verifySignedUrl as a}from"./packem_shared/buildSignedUrl-Bg2beHZA.mjs";export{g as buildPresignedUrl,p as buildSignedUrl,o as createBucketStorage,i as createStorage,d as scopeKey,a as verifySignedUrl};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{AsyncLocalStorage as A}from"node:async_hooks";import{LunoraError as y}from"@lunora/errors";import{ERRORS as R,parseTusMetadata as I,File as L}from"@visulima/storage";import{Rest as F,Multipart as P,Tus as _}from"@visulima/storage/handler/http/fetch";const f="1.0.0",g=(t,e)=>Response.json({error:e},{headers:{"content-type":"application/json","Tus-Resumable":f},status:t}),M=t=>{try{return{metadata:Object.fromEntries(Object.entries(I(t)))}}catch(e){return{error:e instanceof Error?e.message:"Invalid Upload-Metadata header"}}},k=t=>{const e=new Headers(t.headers),r=e.get("Tus-Extension");return e.delete("Tus-Checksum-Algorithm"),r!==null&&e.set("Tus-Extension",r.split(",").filter(n=>n.trim()!=="checksum").join(",")),new Response(t.body,{headers:e,status:t.status,statusText:t.statusText})},C={declared:{contentType:void 0,metadata:{}}},D=t=>({check:e=>{if(e.method!=="POST"&&e.method!=="PATCH")return C;const r=M(e.headers.get("Upload-Metadata")??"");return"error"in r?{refusal:g(400,{code:"BadRequestError",message:r.error,name:"BadRequestError"})}:!t&&e.headers.has("Upload-Checksum")?{refusal:g(400,{code:R.UNSUPPORTED_CHECKSUM_ALGORITHM,message:"Upload-Checksum is not supported on this upload route: its storage verifies no checksums",name:"BadRequestError"})}:{declared:{contentType:void 0,metadata:r.metadata}}},finish:(e,r)=>e.method==="OPTIONS"&&!t?k(r):r}),z=100*1024*1024,H=16*1024*1024,S=new WeakMap,O=new A,ce=()=>{const t={get:()=>{const e=O.getStore();if(e?.issuer!==t)throw new y("INTERNAL","@lunora/storage: no upload grant from this UploadContext is active. Read it from a storage callback of a request `authorize` admitted with its grant()");return e.context},grant:e=>{const r=Object.freeze({});return S.set(r,{context:e,issuer:t}),r}};return t},m=(t,e,r)=>{const n={"content-type":"application/json"};return t==="tus"&&(n["Tus-Resumable"]=f),Response.json({error:r},{headers:n,status:e})},h=t=>m(t,403,{code:"FORBIDDEN",message:"Upload denied by authorization policy",name:"ForbiddenError"}),w=t=>m(t,413,{code:"REQUEST_ENTITY_TOO_LARGE",message:"Upload exceeds the configured maxFileSize",name:"RequestEntityTooLargeError"}),N={"chunked-rest":new Set(["HEAD","OPTIONS","PATCH","POST","PUT"]),multipart:new Set(["OPTIONS","POST"]),tus:new Set(["DELETE","HEAD","OPTIONS","PATCH","POST"])},E=(t,e)=>{const r=m(t,405,{code:"METHOD_NOT_ALLOWED",message:e,name:"MethodNotAllowedError"});return r.headers.set("Allow",[...N[t]].join(", ")),r},x=["X-HTTP-Method-Override","X-HTTP-Method","X-Method-Override"],v=/^\d+$/u,j=/\.[^.]+$/u,B=/^[\w-]{1,255}$/u,X=(t,e)=>{const r=(new URL(t.url).pathname.split("/").findLast(Boolean)??"").replace(j,""),n=t.headers.get("Content-Length")?.trim()??"",o=v.test(n)?Number(n):Number.NaN;return B.test(r)&&Number.isSafeInteger(o)&&o<=e.maxUploadSize?r:void 0},G=t=>typeof t=="object"&&t!==null&&t.UploadErrorCode===R.FILE_NOT_FOUND,$=async(t,e)=>{try{return await e.getMeta(t),!0}catch(r){return!G(r)}},b=(t,e)=>{if(e==="multipart")return;let r;for(const n of["Upload-Length","X-Total-Size","Content-Length"]){const o=t.headers.get(n);if(o===null)continue;const s=v.test(o.trim())?Number(o):Number.NaN,a=Number.isSafeInteger(s)?s:Number.POSITIVE_INFINITY;(r===void 0||a>r)&&(r=a)}return r},Y=(t,e,r,n)=>{const{method:o}=t;if(!N[e].has(o))return{refusal:E(e,`${o} is not allowed on this upload route: it is write-only. Serve stored files with ctx.storage.download() or a signed URL`)};const s=x.find(u=>t.headers.has(u));if(s!==void 0)return{refusal:E(e,`${s} is not allowed on this upload route: send the request with the method itself`)};const a=b(t,e);return a!==void 0&&a>r?{refusal:w(e)}:n.check(t)},V=t=>{try{const e=JSON.parse(t??"{}");return e!==null&&typeof e=="object"&&!Array.isArray(e)?e:{}}catch{return{}}},W={check:t=>{const e=t.headers.get("Content-Type");return{declared:{contentType:e===null||e===""?"application/octet-stream":e,metadata:V(t.headers.get("X-File-Metadata"))}}},finish:(t,e)=>e},J={check:()=>({declared:{contentType:void 0,metadata:{}}}),finish:(t,e)=>e},K=t=>{const e=new L({contentType:t.contentType,metadata:t.metadata}),r={};for(const[n,o]of Object.entries(t.metadata))r[n]=typeof o=="string"?o:JSON.stringify(o);return{contentType:e.contentType,metadata:r}},Q=(t,e)=>e!=="multipart"&&(t.method==="POST"||t.method==="PUT"),Z=async(t,e,r,n,o)=>{const s=b(e.request,e.protocol);let a;try{a=await t({...e,...K(n),declaredSize:s,granted:r})}catch{return h(e.protocol)}if(a!==void 0)return typeof a!="number"||!Number.isFinite(a)||a<0?h(e.protocol):s===void 0||s>Math.min(a,o)?w(e.protocol):void 0},q=(t,e)=>{if(t!=="tus"||e.headers.has("Tus-Resumable"))return e;const r=new Response(e.body,e);return r.headers.set("Tus-Resumable",f),r},ee=async(t,e)=>{let r;try{r=await t(e)}catch{return h(e.protocol)}if(r instanceof Response)return q(e.protocol,r);if(r===!0)return{granted:void 0};const n=typeof r=="object"&&r!==null?S.get(r):void 0;return n===void 0?h(e.protocol):{granted:n}},te=(t,e)=>t==="chunked-rest"?new F(e):t==="multipart"?new P(e):new _(e),re=(t,e)=>t==="tus"?D(e.checksumTypes.length>0):t==="chunked-rest"?W:J,de=t=>{const e=t.protocol??"tus",r=t.maxFileSize??z;if(!Number.isFinite(r)||r<0)throw new y("VALIDATION_ERROR",`@lunora/storage: maxFileSize must be a finite, non-negative number (received ${String(r)})`);const n={allowMethodOverride:!1,disableTerminationForFinishedUploads:!0,maxChecksumBufferSize:H,maxFileSize:r,storage:t.storage},o=te(e,n),s=re(e,t.storage),{authorize:a,maxFileSizeFor:u}=t;a===void 0&&!t.silent&&!t.public&&console.warn("@lunora/storage: createUploadHandler() has no `authorize` — this mounts an unauthenticated, unbounded-write endpoint. Pass an RLS `authorize` gate, or set `public: true` (or `silent: true`) to confirm this bucket is intentionally open.");const U=async(i,d,l,c)=>{if(u!==void 0&&Q(i,e)){const T=await Z(u,d,l?.context,c,r);if(T!==void 0)return T}const p=e==="chunked-rest"&&i.method==="PUT"?X(i,t.storage):void 0;return p!==void 0&&await $(p,t.storage)?m(e,409,{code:"FileConflict",message:"A file already exists under this name",name:"ConflictError"}):s.finish(i,await o.fetch(i))};return{fetch:async i=>{const d=Y(i,e,r,s);if("refusal"in d)return d.refusal;const l={method:i.method,protocol:e,request:i,url:new URL(i.url)},c=a===void 0?{granted:void 0}:await ee(a,l);return c instanceof Response?c:O.run(c.granted,async()=>U(i,l,c.granted,d.declared))},protocol:e}};export{z as DEFAULT_MAX_UPLOAD_BYTES,ce as createUploadContext,de as createUploadHandler};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{N as t,a as o,R as r,o as e,p}from"./r2-upload-part-writer-M7dEI00-.mjs";export{t as NAME_TAKEN,o as R2UploadPartWriter,r as R2_PART_SIZE,e as objectExists,p as putObject};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import{LunoraError as f}from"@lunora/errors";import{b as g,v as D,m as K}from"./internal-B047mSU9.mjs";const $="auto",w="s3",p="AWS4-HMAC-SHA256",R=10080*60,x=900,r=new TextEncoder,z=(t,o)=>t[0]<o[0]?-1:t[0]>o[0]?1:0,i=t=>encodeURIComponent(t).replaceAll(/[!'()*]/gu,o=>`%${o.codePointAt(0)?.toString(16).toUpperCase()??""}`),M=t=>t.split("/").map(o=>i(o)).join("/"),X=async t=>g(await crypto.subtle.digest("SHA-256",r.encode(t))),a=async(t,o)=>{const n=await crypto.subtle.importKey("raw",t,{hash:"SHA-256",name:"HMAC"},!1,["sign"]);return crypto.subtle.sign("HMAC",n,r.encode(o))},_=64,b=new Map,N=async(t,o)=>K(b,`${o}
|
|
2
|
+
${t}`,async()=>{const n=await a(r.encode(`AWS4${t}`),o),d=await a(n,$),s=await a(d,w),c=await a(s,"aws4_request");return crypto.subtle.importKey("raw",c,{hash:"SHA-256",name:"HMAC"},!1,["sign"])},_),O=t=>{const o=t.jurisdiction===void 0?"":`${t.jurisdiction}.`;return`${t.accountId}.${o}r2.cloudflarestorage.com`},P=t=>{const o=`${t.toISOString().replaceAll(/[:-]/gu,"").slice(0,15)}Z`;return{amzDate:o,dateStamp:o.slice(0,8)}},G=async t=>{const{credentials:o,key:n}=t,d=t.method??"GET";let s=x;if(t.expiresInSeconds!==void 0){s=Math.floor(t.expiresInSeconds);const e=D(s,R);if(e!==void 0)throw new f("VALIDATION_ERROR",`@lunora/storage: ${e}`)}const c=O(o),E=new Date(t.now?.()??Date.now()),{amzDate:u,dateStamp:A}=P(E),m=`${A}/${$}/${w}/aws4_request`,y=`/${i(o.bucket)}/${M(n)}`,S=[["X-Amz-Algorithm",p],["X-Amz-Credential",`${o.accessKeyId}/${m}`],["X-Amz-Date",u],["X-Amz-Expires",s.toString()],["X-Amz-SignedHeaders","host"]].map(([e,l])=>[i(e),i(l)]).toSorted(z).map(([e,l])=>`${e}=${l}`).join("&"),h=[d,y,S,`host:${c}
|
|
3
|
+
`,"host","UNSIGNED-PAYLOAD"].join(`
|
|
4
|
+
`),I=[p,u,m,await X(h)].join(`
|
|
5
|
+
`),H=await N(o.secretAccessKey,A),C=g(await crypto.subtle.sign("HMAC",H,r.encode(I)));return`https://${c}${y}?${S}&X-Amz-Signature=${C}`};export{G as buildPresignedUrl};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import{LunoraError as f}from"@lunora/errors";import{i as y,c as u,e as p,s as v,t as U,f as N,d as w,v as I,M as $}from"./internal-B047mSU9.mjs";const k=/^\//,b=(e,s,r,t,o,a)=>{const n=`${e}
|
|
2
|
+
${s.toLowerCase()}
|
|
3
|
+
${r}
|
|
4
|
+
${t}
|
|
5
|
+
${String(o)}`;return a===void 0?n:`${n}
|
|
6
|
+
${a}`},x=async e=>{const s=e.method??"GET",r=e.expiresInSeconds??3600,t=I(r,$);if(t!==void 0)throw new f("VALIDATION_ERROR",`@lunora/storage: ${t}`);const o=s==="PUT"?e.contentType:void 0;let a="";try{a=new URL(e.baseUrl).pathname}catch{}if(a!==""&&!y(a))throw new f("VALIDATION_ERROR",`@lunora/storage: baseUrl must not carry a path ("${a}") — the key is verified from the full URL pathname, so a subpath base would make every signed URL fail verification`);if(u(e.key),u(e.bucketName),e.bucketName==="")throw new f("VALIDATION_ERROR","@lunora/storage: bucketName must not be empty");if(e.key==="")throw new f("VALIDATION_ERROR","@lunora/storage: key must not be empty");const n=Math.floor(Date.now()/1e3)+r,c=p(e.baseUrl),i=await v(e.secret,b(s,c,e.bucketName,e.key,n,o)),m=U(e.baseUrl),l=e.key.split("/").map(h=>encodeURIComponent(h)).join("/"),d=o===void 0?"":`&ct=${encodeURIComponent(o)}`;return`${m}/${l}?exp=${String(n)}&method=${s}&bucket=${encodeURIComponent(e.bucketName)}&sig=${i}${d}`},E=async(e,s,r)=>{let t;try{t=e instanceof URL?e:new URL(e)}catch{return{reason:"malformed",valid:!1}}const o=t.searchParams.get("exp"),a=o===null?Number.NaN:Number(o),n=t.searchParams.get("sig"),c=t.searchParams.get("method")??"GET",i=t.searchParams.get("bucket"),m=t.searchParams.get("ct")??void 0;if(!n||!i||!Number.isInteger(a))return{reason:"malformed",valid:!1};if(a<Math.floor(Date.now()/1e3))return{reason:"expired",valid:!1};if(c!=="GET"&&c!=="PUT")return{reason:"malformed",valid:!1};let l,d;try{l=t.pathname.replace(k,"").split("/").map(R=>decodeURIComponent(R)).join("/"),u(l),u(i),d=N(n)}catch{return{reason:"malformed",valid:!1}}const h=r?.expectedHost===void 0?t.host:p(r.expectedHost);return await w(s,b(c,h,i,l,a,m),d)?{bucketName:i,contentType:m,key:l,method:c,valid:!0}:{reason:"bad_signature",valid:!1}};export{x as buildSignedUrl,E as verifySignedUrl};
|