@lunora/storage 1.0.0-alpha.7 → 1.0.0-alpha.71
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 +221 -260
- package/dist/index.d.ts +221 -260
- package/dist/index.mjs +1 -4
- package/dist/packem_shared/DEFAULT_MAX_UPLOAD_BYTES-CO3auxph.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/createStorage-YPzdUgGl.mjs +1 -0
- package/dist/packem_shared/internal-B5yyxF10.mjs +1 -0
- package/dist/upload.d.mts +175 -0
- package/dist/upload.d.ts +175 -0
- package/dist/upload.mjs +1 -0
- package/package.json +9 -2
- package/dist/packem_shared/buildPresignedUrl-DzPwi1bY.mjs +0 -70
- package/dist/packem_shared/buildSignedUrl-DX535yKv.mjs +0 -116
- package/dist/packem_shared/createBucketStorage-JYdrS4e5.mjs +0 -28
- package/dist/packem_shared/createStorage-fwSr3UdU.mjs +0 -255
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as n}from"@lunora/errors";const g=(e,a={})=>{const o=Object.keys(e),[u]=o;if(u===void 0)throw new n("INTERNAL","@lunora/storage: createBucketStorage requires at least one bucket");if(a.default!==void 0&&!Object.hasOwn(e,a.default))throw new n("INTERNAL",`@lunora/storage: default bucket "${a.default}" is not in the bucket map (have: ${o.join(", ")})`);const t=a.default??(e.default?"default":u),c=e[t];if(c===void 0)throw new n("INTERNAL",`@lunora/storage: default bucket "${t}" is not in the bucket map (have: ${o.join(", ")})`);const s=[...new Set([t,...o])],d=r=>{const f=Object.hasOwn(e,r)?e[r]:void 0,i=r===t?c:f;if(!i)throw new n("INTERNAL",`@lunora/storage: no bucket registered for "${r}". Known buckets: ${s.join(", ")}`);return{...i,bucket:l=>d(l),bucketName:r}};return d(t)};export{g as createBucketStorage};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as n}from"@lunora/errors";import{t as U,h as M,a as I,b as f}from"./internal-B5yyxF10.mjs";import{buildPresignedUrl as S}from"./buildPresignedUrl-U55ktL8x.mjs";import{buildSignedUrl as _}from"./buildSignedUrl-czz1xjLU.mjs";const w=1024,v=1e3,k=100,A=16*1024*1024,D=e=>{const a=e.checksums?.sha256;if(a===void 0)return e;const c=f(a),l=I(new Uint8Array(a));return new Proxy(e,{get(u,s){if(s==="sha256")return c;if(s==="sha256Base64")return l;const d=Reflect.get(u,s,u);return typeof d=="function"?d.bind(u):d},has(u,s){return s==="sha256"||s==="sha256Base64"||Reflect.has(u,s)}})},B=e=>{const a=e.checksums?.sha256;return{contentType:e.httpMetadata?.contentType,customMetadata:e.customMetadata,key:e.key,sha256:a===void 0?void 0:f(a),size:e.size,uploaded:e.uploaded===void 0?void 0:e.uploaded.getTime()}},y=e=>{const a=e.checksums?.sha256;return{checksums:e.checksums,customMetadata:e.customMetadata,etag:e.etag,httpEtag:e.httpEtag,httpMetadata:e.httpMetadata,key:e.key,sha256:a===void 0?void 0:f(a),sha256Base64:a===void 0?void 0:I(new Uint8Array(a)),size:e.size,uploaded:e.uploaded}},O=async(e,a)=>{let c=0;const l=s=>s instanceof ArrayBuffer?new Uint8Array(s):ArrayBuffer.isView(s)?new Uint8Array(s.buffer,s.byteOffset,s.byteLength):void 0,u=new TransformStream({transform(s,d){const m=l(s);if(m===void 0){d.error(new n("VALIDATION_ERROR","@lunora/storage: stream chunk is not a byte chunk; cannot enforce maxSize"));return}if(c+=m.byteLength,c>a){d.error(new n("PAYLOAD_TOO_LARGE",`@lunora/storage: stream body exceeds maxSize (> ${String(a)} bytes)`));return}d.enqueue(m)}});return await new Response(e.pipeThrough(u)).blob()},V=new TextEncoder,R=e=>V.encode(e).length,x=e=>{if(e instanceof ArrayBuffer||ArrayBuffer.isView(e))return e.byteLength;if(typeof e=="string")return R(e);if(typeof e!="object"||e===null)return;const{size:a}=e;return typeof a=="number"?a:void 0},$=async(e,a)=>{if(!Number.isFinite(a)||a<0)throw new n("VALIDATION_ERROR",`@lunora/storage: maxSize must be a finite, non-negative number (received ${String(a)})`);if(e instanceof ReadableStream){if(a>A)throw new n("VALIDATION_ERROR",`@lunora/storage: maxSize ${String(a)} exceeds the ${String(A)}-byte ceiling for a streamed body, which must be buffered to be capped — use createMultipartUpload() or createUploadHandler() for objects this large`);return await O(e,a)}const c=x(e);if(c===void 0)throw new n("VALIDATION_ERROR","@lunora/storage: cannot measure body length, so maxSize cannot be enforced");if(c>a)throw new n("PAYLOAD_TOO_LARGE",`@lunora/storage: body exceeds maxSize (${String(c)} > ${String(a)})`);return e},i=e=>{if(typeof e!="string"||e.length===0)throw new n("VALIDATION_ERROR","@lunora/storage: key must be a non-empty string");if(R(e)>w)throw new n("VALIDATION_ERROR",`@lunora/storage: key exceeds ${String(w)}-byte limit`);if(e.includes("\0"))throw new n("VALIDATION_ERROR","@lunora/storage: key contains NUL byte");if(M(e))throw new n("VALIDATION_ERROR","@lunora/storage: key contains a control character (including CR/LF)");if(e.startsWith("/"))throw new n("VALIDATION_ERROR","@lunora/storage: key must not start with `/`");if(e.split("/").includes(".."))throw new n("VALIDATION_ERROR","@lunora/storage: key contains a `..` path component")},C=e=>{if(e.allowedContentTypes!==void 0){if(e.contentType===void 0)throw new n("VALIDATION_ERROR","@lunora/storage: contentType is required when allowedContentTypes is set");if(!e.allowedContentTypes.includes(e.contentType))throw new n("VALIDATION_ERROR",`@lunora/storage: contentType "${e.contentType}" not in allowedContentTypes`)}},F=(e,a)=>{i(e),i(a);const l=`${e.endsWith("/")?e.slice(0,-1):e}/${a}`;if(R(l)>w)throw new n("VALIDATION_ERROR",`@lunora/storage: scoped key exceeds ${String(w)}-byte limit`);return l},X=e=>{if(!e.bucket)throw new n("INTERNAL","@lunora/storage: `bucket` is required");if(typeof e.bucketName!="string"||e.bucketName==="")throw new n("INTERNAL",'@lunora/storage: `bucketName` is required — pass the name this bucket is registered under (`"default"` for a single-bucket app)');const a=async(r,t,o={})=>{i(r),C(o);const g=o.maxSize===void 0?t:await $(t,o.maxSize),h=await e.bucket.put(r,g,{customMetadata:o.customMetadata,httpMetadata:o.contentType?{contentType:o.contentType}:void 0,...o.sha256===void 0?{}:{sha256:o.sha256}});return{etag:h.etag,httpEtag:h.httpEtag??`"${h.etag}"`,key:h.key}},c=async(r,t={})=>{i(r);const o=await(t.range?e.bucket.get(r,{range:t.range}):e.bucket.get(r));return o&&D(o)},l=async r=>{i(r),await e.bucket.delete(r)},u=async r=>{i(r);let t;if(e.bucket.head)t=await e.bucket.head(r);else{const o=await e.bucket.get(r);await o?.body?.cancel(),t=o}return t&&y(t)},s=async r=>{const t=await u(r);return t&&B(t)},d=async(r,t={})=>{if(r?.includes("\0"))throw new n("VALIDATION_ERROR","@lunora/storage: prefix contains NUL byte");if(t.limit!==void 0&&(!Number.isInteger(t.limit)||t.limit<=0))throw new n("VALIDATION_ERROR",`@lunora/storage: list limit must be a positive integer (received ${String(t.limit)})`);const o=Math.min(t.limit??k,v),g=await e.bucket.list({cursor:t.cursor,delimiter:t.delimiter,include:["customMetadata","httpMetadata"],limit:o,prefix:r}),{delimitedPrefixes:h}=g;return{cursor:g.cursor,delimitedPrefixes:h,objects:g.objects.map(E=>y(E)),truncated:g.truncated}},m=r=>{if(!e.publicBaseUrl)throw new n("INTERNAL","@lunora/storage: `publicBaseUrl` is required for getUrl()");i(r);const t=r.split("/").map(o=>encodeURIComponent(o)).join("/");return`${U(e.publicBaseUrl)}/${t}`},T=async(r,t={})=>{if(!e.publicBaseUrl)throw new n("INTERNAL","@lunora/storage: `publicBaseUrl` is required for getSignedUrl()");if(!e.signingSecret)throw new n("INTERNAL","@lunora/storage: `signingSecret` is required for getSignedUrl()");return i(r),_({baseUrl:e.publicBaseUrl,bucketName:e.bucketName,contentType:t.contentType,expiresInSeconds:t.expiresInSeconds,key:r,method:t.method,secret:e.signingSecret})},b=async(r,t={})=>{if(i(r),!e.bucket.createMultipartUpload)throw new n("INTERNAL","@lunora/storage: bucket binding does not support multipart uploads (createMultipartUpload)");return e.bucket.createMultipartUpload(r,{customMetadata:t.customMetadata,httpMetadata:t.contentType?{contentType:t.contentType}:void 0})},N=(r,t)=>{if(i(r),typeof t!="string"||t.length===0)throw new n("VALIDATION_ERROR","@lunora/storage: resumeMultipartUpload requires a non-empty uploadId");if(!e.bucket.resumeMultipartUpload)throw new n("INTERNAL","@lunora/storage: bucket binding does not support multipart uploads (resumeMultipartUpload)");return e.bucket.resumeMultipartUpload(r,t)},L=async(r,t={})=>{if(!e.s3)throw new n("INTERNAL","@lunora/storage: `s3` credentials are required for getPresignedUrl() — pass { accountId, accessKeyId, secretAccessKey, bucket }");return i(r),S({credentials:e.s3,expiresInSeconds:t.expiresInSeconds,key:r,method:t.method})},p=async(r,t={})=>T(r,{contentType:t.contentType,expiresInSeconds:t.expiresInSeconds,method:"PUT"});return{bucketName:e.bucketName,createMultipartUpload:b,delete:l,download:c,generateUploadUrl:p,getMetadata:s,getPresignedUrl:L,getSignedUrl:T,getUrl:m,head:u,list:d,resumeMultipartUpload:N,store:a,upload:a}};export{X as createStorage,F as scopeKey};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const p=t=>{let n="";for(let r=0;r<t.length;r+=32768)n+=String.fromCharCode(...t.subarray(r,r+32768));return btoa(n)},d=t=>{const n=atob(t),e=new Uint8Array(n.length);for(let r=0;r<n.length;r+=1)e[r]=n.codePointAt(r)??0;return e},s="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",u=t=>{let n="",e=0;const r=t.length-2;for(;e<r;e+=3){const o=t[e]<<16|t[e+1]<<8|t[e+2];n+=s.charAt(o>>18&63)+s.charAt(o>>12&63)+s.charAt(o>>6&63)+s.charAt(o&63)}const c=t.length-e;if(c===1){const o=t[e]<<16;n+=s.charAt(o>>18&63)+s.charAt(o>>12&63)}else if(c===2){const o=t[e]<<16|t[e+1]<<8;n+=s.charAt(o>>18&63)+s.charAt(o>>12&63)+s.charAt(o>>6&63)}return n},_=t=>{const n=t.replaceAll("-","+").replaceAll("_","/"),e=n+"=".repeat((4-n.length%4)%4);return d(e)},A=(t,n)=>{if(t.size<n)return;const e=t.keys().next().value;e!==void 0&&t.delete(e)},f=(t,n,e,r)=>{const c=t.get(n);if(c!==void 0)return c;A(t,r);const o=e().catch(h=>{throw t.get(n)===o&&t.delete(n),h});return t.set(n,o),o},a=new TextEncoder,w=10080*60,g=/^[a-z][a-z0-9+\-.]*:\/\//i,y=Array.from({length:32},(t,n)=>n),i=new RegExp(`[${y.map(t=>String.fromCodePoint(t)).join("")}]`,"u"),C=64,S=new Map,l=async t=>f(S,t,async()=>crypto.subtle.importKey("raw",a.encode(t),{hash:"SHA-256",name:"HMAC"},!1,["sign","verify"]),C),m=t=>{try{return new URL(t).host}catch{return t.replace(g,"").split("/")[0]??""}},H=t=>{if(i.test(t))throw new TypeError("hmac-url: value must not contain control characters (including CR/LF)")},R=t=>i.test(t),E=/^\/+$/u,x=t=>E.test(t),L=(t,n)=>{if(!Number.isFinite(t)||t<=0)return"expiresInSeconds must be a positive finite number";if(t>n){const e=` (${String(n/86400)} days)`;return`expiresInSeconds must not exceed ${String(n)} seconds${e}`}},T=async(t,n)=>{const e=await l(t),r=await crypto.subtle.sign("HMAC",e,a.encode(n));return u(new Uint8Array(r))},O=async(t,n,e)=>{const r=await l(t);return crypto.subtle.verify("HMAC",r,e,a.encode(n))},v=t=>{const n=new Uint8Array(t);let e="";for(const r of n)e+=r.toString(16).padStart(2,"0");return e},M=t=>{let n=t.length;for(;n>0&&t[n-1]==="/";)n-=1;return t.slice(0,n)};export{w as M,p as a,v as b,H as c,O as d,m as e,_ as f,R as h,x as i,T as s,M as t,L as v};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Tus } from '@visulima/storage/handler/http/fetch';
|
|
2
|
+
import { AwsLightStorage } from '@visulima/storage/provider/aws-light';
|
|
3
|
+
/** Resumable upload wire protocols the handler can speak. */
|
|
4
|
+
type UploadProtocol = "chunked-rest" | "multipart" | "tus";
|
|
5
|
+
/**
|
|
6
|
+
* Default ceiling applied to `maxFileSize` when the caller doesn't supply one
|
|
7
|
+
* (100 MiB). Without SOME cap, an unauthenticated or loosely-authorized
|
|
8
|
+
* handler accepts an unbounded request body — pass an explicit `maxFileSize`
|
|
9
|
+
* to raise or lower this for your app.
|
|
10
|
+
*/
|
|
11
|
+
declare const DEFAULT_MAX_UPLOAD_BYTES: number;
|
|
12
|
+
type UploadHandlerOptions = ConstructorParameters<typeof Tus>[0];
|
|
13
|
+
/** A `@visulima/storage` storage provider (e.g. {@link createR2UploadStorage} or a memory provider in tests). */
|
|
14
|
+
type UploadStorage = UploadHandlerOptions["storage"];
|
|
15
|
+
/**
|
|
16
|
+
* The context handed to {@link CreateUploadHandlerOptions.authorize}. Everything
|
|
17
|
+
* needed to make an RLS decision: the raw `request` (headers/cookies/auth), the
|
|
18
|
+
* `method`, the parsed `url`, and which `protocol` the handler speaks.
|
|
19
|
+
*/
|
|
20
|
+
interface UploadAuthzContext {
|
|
21
|
+
/** The upload method being invoked (`POST` create, `PATCH` chunk, `HEAD` resume, `DELETE`). */
|
|
22
|
+
method: string;
|
|
23
|
+
/** The protocol this handler is mounted for. */
|
|
24
|
+
protocol: UploadProtocol;
|
|
25
|
+
/** The inbound request — inspect headers/cookies to resolve the caller's identity. */
|
|
26
|
+
request: Request;
|
|
27
|
+
/** The parsed request URL (query params, upload-id path segment). */
|
|
28
|
+
url: URL;
|
|
29
|
+
}
|
|
30
|
+
/** Options for {@link createUploadHandler}. */
|
|
31
|
+
interface CreateUploadHandlerOptions {
|
|
32
|
+
/**
|
|
33
|
+
* The RLS gate. Runs before every upload request and denies fail-closed:
|
|
34
|
+
* returning `false` **or throwing** yields a `403`. Omit only for a fully
|
|
35
|
+
* public bucket — the whole point of this handler over the admin path is
|
|
36
|
+
* that uploads are gated by *your* per-user policy, not an admin token.
|
|
37
|
+
*
|
|
38
|
+
* Omitting it mounts an unauthenticated, unbounded-write endpoint, so
|
|
39
|
+
* doing so logs a one-time warning (per handler) unless `silent`/`public`
|
|
40
|
+
* says the omission is intentional.
|
|
41
|
+
*/
|
|
42
|
+
authorize?: (context: UploadAuthzContext) => boolean | Promise<boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* Maximum accepted file size in bytes. Forwarded to the multipart parser
|
|
45
|
+
* (protocol `"multipart"`) and, for `"tus"`/`"chunked-rest"`, enforced by
|
|
46
|
+
* this handler itself against the largest size the request declares across
|
|
47
|
+
* `Upload-Length` (TUS), `X-Total-Size` (chunked REST) and `Content-Length`
|
|
48
|
+
* — see {@link declaredUploadSize}. Defaults to
|
|
49
|
+
* {@link DEFAULT_MAX_UPLOAD_BYTES} (100 MiB) — pass this to raise or lower
|
|
50
|
+
* the ceiling; there is no unbounded option. Must be a finite, non-negative
|
|
51
|
+
* number: anything else (notably a `NaN` from an unset env var) throws at
|
|
52
|
+
* construction rather than disabling the cap.
|
|
53
|
+
*/
|
|
54
|
+
maxFileSize?: number;
|
|
55
|
+
/** Which protocol to speak. Default `"tus"` (the resumable, pause/resume-capable one). */
|
|
56
|
+
protocol?: UploadProtocol;
|
|
57
|
+
/**
|
|
58
|
+
* Set when omitting `authorize` is intentional (a fully public upload
|
|
59
|
+
* bucket) — suppresses the one-time "no authorize gate" warning that would
|
|
60
|
+
* otherwise print when the handler is constructed. Has no effect when
|
|
61
|
+
* `authorize` is provided.
|
|
62
|
+
*/
|
|
63
|
+
public?: boolean;
|
|
64
|
+
/** Suppress the one-time default-open-authorize warning. Alias of `public`. */
|
|
65
|
+
silent?: boolean;
|
|
66
|
+
/** The storage provider the bytes land in (R2 in prod, memory in tests). */
|
|
67
|
+
storage: UploadStorage;
|
|
68
|
+
}
|
|
69
|
+
/** The object returned by {@link createUploadHandler}. */
|
|
70
|
+
interface UploadHandler {
|
|
71
|
+
/**
|
|
72
|
+
* Handle one upload request. Runs the RLS gate, then delegates to the
|
|
73
|
+
* `@visulima/storage` protocol handler. Wire this into your Worker's routing
|
|
74
|
+
* for the path the client uploads to.
|
|
75
|
+
*/
|
|
76
|
+
fetch: (request: Request) => Promise<Response>;
|
|
77
|
+
/** The protocol this handler speaks. */
|
|
78
|
+
protocol: UploadProtocol;
|
|
79
|
+
}
|
|
80
|
+
/** R2 (S3-compatible) credentials + bucket for {@link createR2UploadStorage}. */
|
|
81
|
+
interface R2UploadStorageOptions {
|
|
82
|
+
/** R2 S3 API Access Key ID (from an R2 API token). */
|
|
83
|
+
accessKeyId: string;
|
|
84
|
+
/** Cloudflare account id — used to derive the R2 S3 endpoint host. */
|
|
85
|
+
accountId: string;
|
|
86
|
+
/** Target R2 bucket name. */
|
|
87
|
+
bucket: string;
|
|
88
|
+
/**
|
|
89
|
+
* Explicit R2 S3 endpoint. Defaults to
|
|
90
|
+
* `https://<accountId>.r2.cloudflarestorage.com`. Pass this to pin a
|
|
91
|
+
* jurisdiction (e.g. `<accountId>.eu.r2.cloudflarestorage.com`).
|
|
92
|
+
*/
|
|
93
|
+
endpoint?: string;
|
|
94
|
+
/** Client-side multipart part size (bytes or a size string like `"16MB"`). */
|
|
95
|
+
partSize?: number | string;
|
|
96
|
+
/**
|
|
97
|
+
* Path prefix the handler is mounted on (must match the client endpoint's
|
|
98
|
+
* path). Default `"/"`.
|
|
99
|
+
*/
|
|
100
|
+
path?: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Build an RLS-gated resumable upload handler over a `@visulima/storage`
|
|
104
|
+
* provider. Mount its {@link UploadHandler.fetch} on the route your client
|
|
105
|
+
* uploads to and drive it with `@visulima/storage-client`.
|
|
106
|
+
*/
|
|
107
|
+
declare const createUploadHandler: (options: CreateUploadHandlerOptions) => UploadHandler;
|
|
108
|
+
/**
|
|
109
|
+
* Build an R2-backed storage provider for {@link createUploadHandler} using
|
|
110
|
+
* `@visulima/storage`'s dependency-light `aws-light` provider (`aws4fetch`, no
|
|
111
|
+
* AWS SDK). R2's S3 region alias is always `auto`.
|
|
112
|
+
*
|
|
113
|
+
* Requires an R2 **S3 API** token's Access Key ID / Secret Access Key — the
|
|
114
|
+
* same credential shape `@lunora/storage`'s presigned-URL helpers take. In a
|
|
115
|
+
* Worker the `aws-light` provider needs `nodejs_compat` (it imports
|
|
116
|
+
* `node:stream`).
|
|
117
|
+
*/
|
|
118
|
+
declare const createR2UploadStorage: (options: R2UploadStorageOptions & {
|
|
119
|
+
secretAccessKey: string;
|
|
120
|
+
}) => AwsLightStorage;
|
|
121
|
+
export {
|
|
122
|
+
/**
|
|
123
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
124
|
+
*
|
|
125
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
126
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
127
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
128
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
129
|
+
*/
|
|
130
|
+
type CreateUploadHandlerOptions, DEFAULT_MAX_UPLOAD_BYTES,
|
|
131
|
+
/**
|
|
132
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
133
|
+
*
|
|
134
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
135
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
136
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
137
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
138
|
+
*/
|
|
139
|
+
type R2UploadStorageOptions,
|
|
140
|
+
/**
|
|
141
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
142
|
+
*
|
|
143
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
144
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
145
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
146
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
147
|
+
*/
|
|
148
|
+
type UploadAuthzContext,
|
|
149
|
+
/**
|
|
150
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
151
|
+
*
|
|
152
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
153
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
154
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
155
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
156
|
+
*/
|
|
157
|
+
type UploadHandler,
|
|
158
|
+
/**
|
|
159
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
160
|
+
*
|
|
161
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
162
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
163
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
164
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
165
|
+
*/
|
|
166
|
+
type UploadProtocol,
|
|
167
|
+
/**
|
|
168
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
169
|
+
*
|
|
170
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
171
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
172
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
173
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
174
|
+
*/
|
|
175
|
+
type UploadStorage, createR2UploadStorage, createUploadHandler };
|
package/dist/upload.d.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Tus } from '@visulima/storage/handler/http/fetch';
|
|
2
|
+
import { AwsLightStorage } from '@visulima/storage/provider/aws-light';
|
|
3
|
+
/** Resumable upload wire protocols the handler can speak. */
|
|
4
|
+
type UploadProtocol = "chunked-rest" | "multipart" | "tus";
|
|
5
|
+
/**
|
|
6
|
+
* Default ceiling applied to `maxFileSize` when the caller doesn't supply one
|
|
7
|
+
* (100 MiB). Without SOME cap, an unauthenticated or loosely-authorized
|
|
8
|
+
* handler accepts an unbounded request body — pass an explicit `maxFileSize`
|
|
9
|
+
* to raise or lower this for your app.
|
|
10
|
+
*/
|
|
11
|
+
declare const DEFAULT_MAX_UPLOAD_BYTES: number;
|
|
12
|
+
type UploadHandlerOptions = ConstructorParameters<typeof Tus>[0];
|
|
13
|
+
/** A `@visulima/storage` storage provider (e.g. {@link createR2UploadStorage} or a memory provider in tests). */
|
|
14
|
+
type UploadStorage = UploadHandlerOptions["storage"];
|
|
15
|
+
/**
|
|
16
|
+
* The context handed to {@link CreateUploadHandlerOptions.authorize}. Everything
|
|
17
|
+
* needed to make an RLS decision: the raw `request` (headers/cookies/auth), the
|
|
18
|
+
* `method`, the parsed `url`, and which `protocol` the handler speaks.
|
|
19
|
+
*/
|
|
20
|
+
interface UploadAuthzContext {
|
|
21
|
+
/** The upload method being invoked (`POST` create, `PATCH` chunk, `HEAD` resume, `DELETE`). */
|
|
22
|
+
method: string;
|
|
23
|
+
/** The protocol this handler is mounted for. */
|
|
24
|
+
protocol: UploadProtocol;
|
|
25
|
+
/** The inbound request — inspect headers/cookies to resolve the caller's identity. */
|
|
26
|
+
request: Request;
|
|
27
|
+
/** The parsed request URL (query params, upload-id path segment). */
|
|
28
|
+
url: URL;
|
|
29
|
+
}
|
|
30
|
+
/** Options for {@link createUploadHandler}. */
|
|
31
|
+
interface CreateUploadHandlerOptions {
|
|
32
|
+
/**
|
|
33
|
+
* The RLS gate. Runs before every upload request and denies fail-closed:
|
|
34
|
+
* returning `false` **or throwing** yields a `403`. Omit only for a fully
|
|
35
|
+
* public bucket — the whole point of this handler over the admin path is
|
|
36
|
+
* that uploads are gated by *your* per-user policy, not an admin token.
|
|
37
|
+
*
|
|
38
|
+
* Omitting it mounts an unauthenticated, unbounded-write endpoint, so
|
|
39
|
+
* doing so logs a one-time warning (per handler) unless `silent`/`public`
|
|
40
|
+
* says the omission is intentional.
|
|
41
|
+
*/
|
|
42
|
+
authorize?: (context: UploadAuthzContext) => boolean | Promise<boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* Maximum accepted file size in bytes. Forwarded to the multipart parser
|
|
45
|
+
* (protocol `"multipart"`) and, for `"tus"`/`"chunked-rest"`, enforced by
|
|
46
|
+
* this handler itself against the largest size the request declares across
|
|
47
|
+
* `Upload-Length` (TUS), `X-Total-Size` (chunked REST) and `Content-Length`
|
|
48
|
+
* — see {@link declaredUploadSize}. Defaults to
|
|
49
|
+
* {@link DEFAULT_MAX_UPLOAD_BYTES} (100 MiB) — pass this to raise or lower
|
|
50
|
+
* the ceiling; there is no unbounded option. Must be a finite, non-negative
|
|
51
|
+
* number: anything else (notably a `NaN` from an unset env var) throws at
|
|
52
|
+
* construction rather than disabling the cap.
|
|
53
|
+
*/
|
|
54
|
+
maxFileSize?: number;
|
|
55
|
+
/** Which protocol to speak. Default `"tus"` (the resumable, pause/resume-capable one). */
|
|
56
|
+
protocol?: UploadProtocol;
|
|
57
|
+
/**
|
|
58
|
+
* Set when omitting `authorize` is intentional (a fully public upload
|
|
59
|
+
* bucket) — suppresses the one-time "no authorize gate" warning that would
|
|
60
|
+
* otherwise print when the handler is constructed. Has no effect when
|
|
61
|
+
* `authorize` is provided.
|
|
62
|
+
*/
|
|
63
|
+
public?: boolean;
|
|
64
|
+
/** Suppress the one-time default-open-authorize warning. Alias of `public`. */
|
|
65
|
+
silent?: boolean;
|
|
66
|
+
/** The storage provider the bytes land in (R2 in prod, memory in tests). */
|
|
67
|
+
storage: UploadStorage;
|
|
68
|
+
}
|
|
69
|
+
/** The object returned by {@link createUploadHandler}. */
|
|
70
|
+
interface UploadHandler {
|
|
71
|
+
/**
|
|
72
|
+
* Handle one upload request. Runs the RLS gate, then delegates to the
|
|
73
|
+
* `@visulima/storage` protocol handler. Wire this into your Worker's routing
|
|
74
|
+
* for the path the client uploads to.
|
|
75
|
+
*/
|
|
76
|
+
fetch: (request: Request) => Promise<Response>;
|
|
77
|
+
/** The protocol this handler speaks. */
|
|
78
|
+
protocol: UploadProtocol;
|
|
79
|
+
}
|
|
80
|
+
/** R2 (S3-compatible) credentials + bucket for {@link createR2UploadStorage}. */
|
|
81
|
+
interface R2UploadStorageOptions {
|
|
82
|
+
/** R2 S3 API Access Key ID (from an R2 API token). */
|
|
83
|
+
accessKeyId: string;
|
|
84
|
+
/** Cloudflare account id — used to derive the R2 S3 endpoint host. */
|
|
85
|
+
accountId: string;
|
|
86
|
+
/** Target R2 bucket name. */
|
|
87
|
+
bucket: string;
|
|
88
|
+
/**
|
|
89
|
+
* Explicit R2 S3 endpoint. Defaults to
|
|
90
|
+
* `https://<accountId>.r2.cloudflarestorage.com`. Pass this to pin a
|
|
91
|
+
* jurisdiction (e.g. `<accountId>.eu.r2.cloudflarestorage.com`).
|
|
92
|
+
*/
|
|
93
|
+
endpoint?: string;
|
|
94
|
+
/** Client-side multipart part size (bytes or a size string like `"16MB"`). */
|
|
95
|
+
partSize?: number | string;
|
|
96
|
+
/**
|
|
97
|
+
* Path prefix the handler is mounted on (must match the client endpoint's
|
|
98
|
+
* path). Default `"/"`.
|
|
99
|
+
*/
|
|
100
|
+
path?: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Build an RLS-gated resumable upload handler over a `@visulima/storage`
|
|
104
|
+
* provider. Mount its {@link UploadHandler.fetch} on the route your client
|
|
105
|
+
* uploads to and drive it with `@visulima/storage-client`.
|
|
106
|
+
*/
|
|
107
|
+
declare const createUploadHandler: (options: CreateUploadHandlerOptions) => UploadHandler;
|
|
108
|
+
/**
|
|
109
|
+
* Build an R2-backed storage provider for {@link createUploadHandler} using
|
|
110
|
+
* `@visulima/storage`'s dependency-light `aws-light` provider (`aws4fetch`, no
|
|
111
|
+
* AWS SDK). R2's S3 region alias is always `auto`.
|
|
112
|
+
*
|
|
113
|
+
* Requires an R2 **S3 API** token's Access Key ID / Secret Access Key — the
|
|
114
|
+
* same credential shape `@lunora/storage`'s presigned-URL helpers take. In a
|
|
115
|
+
* Worker the `aws-light` provider needs `nodejs_compat` (it imports
|
|
116
|
+
* `node:stream`).
|
|
117
|
+
*/
|
|
118
|
+
declare const createR2UploadStorage: (options: R2UploadStorageOptions & {
|
|
119
|
+
secretAccessKey: string;
|
|
120
|
+
}) => AwsLightStorage;
|
|
121
|
+
export {
|
|
122
|
+
/**
|
|
123
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
124
|
+
*
|
|
125
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
126
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
127
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
128
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
129
|
+
*/
|
|
130
|
+
type CreateUploadHandlerOptions, DEFAULT_MAX_UPLOAD_BYTES,
|
|
131
|
+
/**
|
|
132
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
133
|
+
*
|
|
134
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
135
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
136
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
137
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
138
|
+
*/
|
|
139
|
+
type R2UploadStorageOptions,
|
|
140
|
+
/**
|
|
141
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
142
|
+
*
|
|
143
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
144
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
145
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
146
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
147
|
+
*/
|
|
148
|
+
type UploadAuthzContext,
|
|
149
|
+
/**
|
|
150
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
151
|
+
*
|
|
152
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
153
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
154
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
155
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
156
|
+
*/
|
|
157
|
+
type UploadHandler,
|
|
158
|
+
/**
|
|
159
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
160
|
+
*
|
|
161
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
162
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
163
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
164
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
165
|
+
*/
|
|
166
|
+
type UploadProtocol,
|
|
167
|
+
/**
|
|
168
|
+
* `@lunora/storage/upload` — the RLS-gated, non-admin resumable upload handler.
|
|
169
|
+
*
|
|
170
|
+
* A separate subpath so the base `@lunora/storage` entry stays lean: the
|
|
171
|
+
* `@visulima/storage` handler dependency only loads for apps that mount an
|
|
172
|
+
* end-user upload endpoint. Pair with `@visulima/storage-client` (or the
|
|
173
|
+
* `@lunora/react` / `@lunora/vue` / … `useUpload` re-exports) on the client.
|
|
174
|
+
*/
|
|
175
|
+
type UploadStorage, createR2UploadStorage, createUploadHandler };
|
package/dist/upload.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{DEFAULT_MAX_UPLOAD_BYTES as r,createR2UploadStorage as o,createUploadHandler as t}from"./packem_shared/DEFAULT_MAX_UPLOAD_BYTES-CO3auxph.mjs";export{r as DEFAULT_MAX_UPLOAD_BYTES,o as createR2UploadStorage,t 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.71",
|
|
4
4
|
"description": "R2-backed storage for Lunora: typed buckets and signed URLs",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -40,13 +40,20 @@
|
|
|
40
40
|
"types": "./dist/index.d.ts",
|
|
41
41
|
"import": "./dist/index.mjs"
|
|
42
42
|
},
|
|
43
|
+
"./upload": {
|
|
44
|
+
"types": "./dist/upload.d.ts",
|
|
45
|
+
"import": "./dist/upload.mjs"
|
|
46
|
+
},
|
|
43
47
|
"./package.json": "./package.json"
|
|
44
48
|
},
|
|
45
49
|
"publishConfig": {
|
|
46
50
|
"access": "public"
|
|
47
51
|
},
|
|
48
52
|
"dependencies": {
|
|
49
|
-
"@lunora/errors": "1.0.0-alpha.
|
|
53
|
+
"@lunora/errors": "1.0.0-alpha.37",
|
|
54
|
+
"@lunora/platform": "1.0.0-alpha.32",
|
|
55
|
+
"@visulima/storage": "2.0.8",
|
|
56
|
+
"aws4fetch": "1.0.20"
|
|
50
57
|
},
|
|
51
58
|
"engines": {
|
|
52
59
|
"node": "^22.15.0 || >=24.11.0"
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
const REGION = "auto";
|
|
2
|
-
const SERVICE = "s3";
|
|
3
|
-
const ALGORITHM = "AWS4-HMAC-SHA256";
|
|
4
|
-
const MIN_EXPIRES_SECONDS = 1;
|
|
5
|
-
const MAX_EXPIRES_SECONDS = 7 * 24 * 60 * 60;
|
|
6
|
-
const DEFAULT_EXPIRES_SECONDS = 900;
|
|
7
|
-
const textEncoder = new TextEncoder();
|
|
8
|
-
const compareEntries = (a, b) => {
|
|
9
|
-
if (a[0] < b[0]) {
|
|
10
|
-
return -1;
|
|
11
|
-
}
|
|
12
|
-
return a[0] > b[0] ? 1 : 0;
|
|
13
|
-
};
|
|
14
|
-
const toHex = (buffer) => {
|
|
15
|
-
const bytes = new Uint8Array(buffer);
|
|
16
|
-
let out = "";
|
|
17
|
-
for (const byte of bytes) {
|
|
18
|
-
out += byte.toString(16).padStart(2, "0");
|
|
19
|
-
}
|
|
20
|
-
return out;
|
|
21
|
-
};
|
|
22
|
-
const encodeRfc3986 = (value) => encodeURIComponent(value).replaceAll(/[!'()*]/gu, (char) => `%${char.codePointAt(0)?.toString(16).toUpperCase() ?? ""}`);
|
|
23
|
-
const encodeKey = (key) => key.split("/").map((segment) => encodeRfc3986(segment)).join("/");
|
|
24
|
-
const sha256Hex = async (input) => toHex(await crypto.subtle.digest("SHA-256", textEncoder.encode(input)));
|
|
25
|
-
const hmac = async (key, message) => {
|
|
26
|
-
const cryptoKey = await crypto.subtle.importKey("raw", key, { hash: "SHA-256", name: "HMAC" }, false, ["sign"]);
|
|
27
|
-
return crypto.subtle.sign("HMAC", cryptoKey, textEncoder.encode(message));
|
|
28
|
-
};
|
|
29
|
-
const deriveSigningKey = async (secretAccessKey, dateStamp) => {
|
|
30
|
-
const dateKey = await hmac(textEncoder.encode(`AWS4${secretAccessKey}`), dateStamp);
|
|
31
|
-
const regionKey = await hmac(dateKey, REGION);
|
|
32
|
-
const serviceKey = await hmac(regionKey, SERVICE);
|
|
33
|
-
return hmac(serviceKey, "aws4_request");
|
|
34
|
-
};
|
|
35
|
-
const endpointHost = (credentials) => {
|
|
36
|
-
const infix = credentials.jurisdiction === void 0 ? "" : `${credentials.jurisdiction}.`;
|
|
37
|
-
return `${credentials.accountId}.${infix}r2.cloudflarestorage.com`;
|
|
38
|
-
};
|
|
39
|
-
const formatAmzDate = (date) => {
|
|
40
|
-
const amzDate = `${date.toISOString().replaceAll(/[:-]/gu, "").slice(0, 15)}Z`;
|
|
41
|
-
return { amzDate, dateStamp: amzDate.slice(0, 8) };
|
|
42
|
-
};
|
|
43
|
-
const buildPresignedUrl = async (parameters) => {
|
|
44
|
-
const { credentials, key } = parameters;
|
|
45
|
-
const method = parameters.method ?? "GET";
|
|
46
|
-
const requested = parameters.expiresInSeconds ?? DEFAULT_EXPIRES_SECONDS;
|
|
47
|
-
const normalised = Number.isFinite(requested) ? requested : DEFAULT_EXPIRES_SECONDS;
|
|
48
|
-
const expires = Math.min(Math.max(MIN_EXPIRES_SECONDS, Math.floor(normalised)), MAX_EXPIRES_SECONDS);
|
|
49
|
-
const host = endpointHost(credentials);
|
|
50
|
-
const date = new Date(parameters.now?.() ?? Date.now());
|
|
51
|
-
const { amzDate, dateStamp } = formatAmzDate(date);
|
|
52
|
-
const credentialScope = `${dateStamp}/${REGION}/${SERVICE}/aws4_request`;
|
|
53
|
-
const canonicalUri = `/${encodeRfc3986(credentials.bucket)}/${encodeKey(key)}`;
|
|
54
|
-
const query = [
|
|
55
|
-
["X-Amz-Algorithm", ALGORITHM],
|
|
56
|
-
["X-Amz-Credential", `${credentials.accessKeyId}/${credentialScope}`],
|
|
57
|
-
["X-Amz-Date", amzDate],
|
|
58
|
-
["X-Amz-Expires", expires.toString()],
|
|
59
|
-
["X-Amz-SignedHeaders", "host"]
|
|
60
|
-
];
|
|
61
|
-
const canonicalQuery = query.map(([name, value]) => [encodeRfc3986(name), encodeRfc3986(value)]).toSorted(compareEntries).map(([name, value]) => `${name}=${value}`).join("&");
|
|
62
|
-
const canonicalRequest = [method, canonicalUri, canonicalQuery, `host:${host}
|
|
63
|
-
`, "host", "UNSIGNED-PAYLOAD"].join("\n");
|
|
64
|
-
const stringToSign = [ALGORITHM, amzDate, credentialScope, await sha256Hex(canonicalRequest)].join("\n");
|
|
65
|
-
const signingKey = await deriveSigningKey(credentials.secretAccessKey, dateStamp);
|
|
66
|
-
const signature = toHex(await hmac(signingKey, stringToSign));
|
|
67
|
-
return `https://${host}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
export { buildPresignedUrl };
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
|
|
3
|
-
const textEncoder = new TextEncoder();
|
|
4
|
-
const MAX_EXPIRES_IN_SECONDS = 7 * 24 * 60 * 60;
|
|
5
|
-
const SCHEME_PREFIX_RE = /^[a-z][a-z0-9+\-.]*:\/\//i;
|
|
6
|
-
const LEADING_SLASH_RE = /^\//;
|
|
7
|
-
const toBase64Url = (bytes) => {
|
|
8
|
-
const binary = String.fromCodePoint(...bytes);
|
|
9
|
-
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
10
|
-
};
|
|
11
|
-
const fromBase64Url = (input) => {
|
|
12
|
-
const padded = input.replaceAll("-", "+").replaceAll("_", "/") + "===".slice((input.length + 3) % 4);
|
|
13
|
-
const binary = atob(padded);
|
|
14
|
-
const bytes = new Uint8Array(binary.length);
|
|
15
|
-
for (let index = 0; index < binary.length; index += 1) {
|
|
16
|
-
bytes[index] = binary.codePointAt(index) ?? 0;
|
|
17
|
-
}
|
|
18
|
-
return bytes;
|
|
19
|
-
};
|
|
20
|
-
const KEY_CACHE_MAX = 64;
|
|
21
|
-
const keyCache = /* @__PURE__ */ new Map();
|
|
22
|
-
const importHmacKey = async (secret) => {
|
|
23
|
-
const cached = keyCache.get(secret);
|
|
24
|
-
if (cached) {
|
|
25
|
-
return cached;
|
|
26
|
-
}
|
|
27
|
-
if (keyCache.size >= KEY_CACHE_MAX) {
|
|
28
|
-
const oldest = keyCache.keys().next().value;
|
|
29
|
-
if (oldest !== void 0) {
|
|
30
|
-
keyCache.delete(oldest);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
const keyPromise = crypto.subtle.importKey("raw", textEncoder.encode(secret), { hash: "SHA-256", name: "HMAC" }, false, ["sign", "verify"]);
|
|
34
|
-
keyCache.set(secret, keyPromise);
|
|
35
|
-
return keyPromise;
|
|
36
|
-
};
|
|
37
|
-
const canonicalize = (method, host, key, exp, contentType) => {
|
|
38
|
-
const base = `${method}
|
|
39
|
-
${host.toLowerCase()}
|
|
40
|
-
${key}
|
|
41
|
-
${String(exp)}`;
|
|
42
|
-
return contentType === void 0 ? base : `${base}
|
|
43
|
-
${contentType}`;
|
|
44
|
-
};
|
|
45
|
-
const extractHost = (input) => {
|
|
46
|
-
try {
|
|
47
|
-
return new URL(input).host;
|
|
48
|
-
} catch {
|
|
49
|
-
const noScheme = input.replace(SCHEME_PREFIX_RE, "");
|
|
50
|
-
return noScheme.split("/")[0] ?? "";
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
const buildSignedUrl = async (args) => {
|
|
54
|
-
const method = args.method ?? "GET";
|
|
55
|
-
const expiresInSeconds = args.expiresInSeconds ?? 60 * 60;
|
|
56
|
-
if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) {
|
|
57
|
-
throw new LunoraError("INTERNAL", "@lunora/storage: expiresInSeconds must be a positive finite number");
|
|
58
|
-
}
|
|
59
|
-
if (expiresInSeconds > MAX_EXPIRES_IN_SECONDS) {
|
|
60
|
-
throw new LunoraError("INTERNAL", `@lunora/storage: expiresInSeconds must not exceed ${String(MAX_EXPIRES_IN_SECONDS)} (7 days)`);
|
|
61
|
-
}
|
|
62
|
-
const contentType = method === "PUT" ? args.contentType : void 0;
|
|
63
|
-
const exp = Math.floor(Date.now() / 1e3) + expiresInSeconds;
|
|
64
|
-
const host = extractHost(args.baseUrl);
|
|
65
|
-
const cryptoKey = await importHmacKey(args.secret);
|
|
66
|
-
const signature = await crypto.subtle.sign("HMAC", cryptoKey, textEncoder.encode(canonicalize(method, host, args.key, exp, contentType)));
|
|
67
|
-
const sig = toBase64Url(new Uint8Array(signature));
|
|
68
|
-
const base = args.baseUrl.endsWith("/") ? args.baseUrl.slice(0, -1) : args.baseUrl;
|
|
69
|
-
const safeKey = args.key.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
70
|
-
const ctParameter = contentType === void 0 ? "" : `&ct=${encodeURIComponent(contentType)}`;
|
|
71
|
-
return `${base}/${safeKey}?exp=${String(exp)}&method=${method}&sig=${sig}${ctParameter}`;
|
|
72
|
-
};
|
|
73
|
-
const verifySignedUrl = async (input, secret, options) => {
|
|
74
|
-
let url;
|
|
75
|
-
try {
|
|
76
|
-
url = input instanceof URL ? input : new URL(input);
|
|
77
|
-
} catch {
|
|
78
|
-
return { reason: "malformed", valid: false };
|
|
79
|
-
}
|
|
80
|
-
const expRaw = url.searchParams.get("exp");
|
|
81
|
-
const exp = expRaw === null ? Number.NaN : Number(expRaw);
|
|
82
|
-
const sig = url.searchParams.get("sig");
|
|
83
|
-
const method = url.searchParams.get("method") ?? "GET";
|
|
84
|
-
const contentType = url.searchParams.get("ct") ?? void 0;
|
|
85
|
-
if (!sig || !Number.isInteger(exp)) {
|
|
86
|
-
return { reason: "malformed", valid: false };
|
|
87
|
-
}
|
|
88
|
-
if (exp < Math.floor(Date.now() / 1e3)) {
|
|
89
|
-
return { reason: "expired", valid: false };
|
|
90
|
-
}
|
|
91
|
-
if (method !== "GET" && method !== "PUT") {
|
|
92
|
-
return { reason: "malformed", valid: false };
|
|
93
|
-
}
|
|
94
|
-
let key;
|
|
95
|
-
let sigBytes;
|
|
96
|
-
try {
|
|
97
|
-
key = url.pathname.replace(LEADING_SLASH_RE, "").split("/").map((segment) => decodeURIComponent(segment)).join("/");
|
|
98
|
-
sigBytes = fromBase64Url(sig);
|
|
99
|
-
} catch {
|
|
100
|
-
return { reason: "malformed", valid: false };
|
|
101
|
-
}
|
|
102
|
-
const host = options?.expectedHost === void 0 ? url.host : extractHost(options.expectedHost);
|
|
103
|
-
const cryptoKey = await importHmacKey(secret);
|
|
104
|
-
const valid = await crypto.subtle.verify(
|
|
105
|
-
"HMAC",
|
|
106
|
-
cryptoKey,
|
|
107
|
-
sigBytes,
|
|
108
|
-
textEncoder.encode(canonicalize(method, host, key, exp, contentType))
|
|
109
|
-
);
|
|
110
|
-
if (!valid) {
|
|
111
|
-
return { reason: "bad_signature", valid: false };
|
|
112
|
-
}
|
|
113
|
-
return { contentType, key, method, valid: true };
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
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 };
|