@better-s3/server 3.1045.1 → 3.1045.2

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/dist/index.js CHANGED
@@ -1,3 +1,726 @@
1
- import {createPresignedPost}from'@aws-sdk/s3-presigned-post';import {getSignedUrl}from'@aws-sdk/s3-request-presigner';import {PutObjectCommand,HeadObjectCommand,GetObjectCommand,DeleteObjectCommand,CreateMultipartUploadCommand,UploadPartCommand,ListPartsCommand,CompleteMultipartUploadCommand,AbortMultipartUploadCommand,GetObjectAclCommand}from'@aws-sdk/client-s3';async function y(e){try{let a=await e.json();return a&&typeof a=="object"?a:null}catch{return null}}function m(e,a){let r=typeof e=="string"?e.trim():"";return r||Response.json({message:`${a} is required`},{status:400})}function w(e){let a=Number(e);return Number.isFinite(a)&&a>0?Math.floor(a):600}function d(e){return async a=>{try{return await e(a)}catch(r){let t=r.code,s=t==="EAI_AGAIN"||t==="ECONNREFUSED"||t==="ECONNRESET"||t==="ETIMEDOUT"||t==="ENOTFOUND"?`S3 endpoint unreachable (${t}): check your endpoint URL and network connectivity`:r instanceof Error?r.message:"Internal server error";return console.error("[S3 API]",s),Response.json({message:s},{status:502})}}}async function l(e,a){if(!e)return null;try{return await e(a),null}catch(r){let t=r instanceof Error?r.message:"Forbidden",n=typeof r?.status=="number"?r.status:403;return Response.json({message:t},{status:n})}}function b(e){let a=e.replace(/[^\x20-\x7E]/g,"_").replace(/["\\]/g,"_"),r=encodeURIComponent(e);return `attachment; filename="${a}"; filename*=UTF-8''${r}`}function h(e,a,r,t){if(t!=="public-read"||!e)return;let n=e(a);if(n)return `${n.replace(/\/$/,"")}/${r}`}function I(e){if(!e)return;let a=e.match(/filename\*=UTF-8''([^;\s]+)/i);if(a)try{return decodeURIComponent(a[1])}catch{}return e.match(/filename="([^"]*)"/i)?.[1]}function M(e){return d(async a=>{let r=await y(a);if(!r)return Response.json({message:"Invalid JSON payload"},{status:400});let t=m(r.key,"key");if(t instanceof Response)return t;let n=r.bucket?.trim()||e.defaultBucket,s=w(r.expiresIn),o=r.acl==="public-read"?"public-read":"private",i=r.contentType?.trim()||"application/octet-stream",p=typeof r.fileSize=="number"&&r.fileSize>0?Math.floor(r.fileSize):null,c=await l(e.upload?.presignGuard,{request:a,key:t,bucket:n,contentType:r.contentType,fileSize:p??void 0,metadata:r.metadata,acl:o});if(c)return c;let u=e.upload?.method??"post";if(u==="put"&&e.upload?.requireFileSize&&p===null)return Response.json({message:"fileSize is required when upload.requireFileSize is enabled"},{status:400});if(u==="put"){let R={"Content-Type":i};r.fileName&&(R["Content-Disposition"]=b(r.fileName));let S=await getSignedUrl(e.s3,new PutObjectCommand({Bucket:n,Key:t,ContentType:i,ACL:o,...r.fileName?{ContentDisposition:b(r.fileName)}:{},...p!==null?{ContentLength:p}:{}}),{expiresIn:s,...p!==null?{signableHeaders:new Set(["content-length"])}:{}});return await e.upload?.onPresigned?.({request:a,key:t,bucket:n,contentType:r.contentType,metadata:r.metadata,acl:o,url:S,expiresIn:s}),Response.json({bucket:n,key:t,url:S,headers:R,expiresIn:s,method:"put"})}let C={acl:o,"Content-Type":i};if(r.fileName&&(C["Content-Disposition"]=b(r.fileName)),r.metadata)for(let[R,S]of Object.entries(r.metadata))C[`x-amz-meta-${R}`]=S;let f=p??1,P=p??void 0,{url:x,fields:N}=await createPresignedPost(e.s3,{Bucket:n,Key:t,Conditions:P!==void 0?[["content-length-range",f,P]]:[["content-length-range",f,Number.MAX_SAFE_INTEGER]],Fields:C,Expires:s});return await e.upload?.onPresigned?.({request:a,key:t,bucket:n,contentType:r.contentType,metadata:r.metadata,acl:o,url:x,expiresIn:s}),Response.json({bucket:n,key:t,url:x,fields:N,expiresIn:s,method:"post"})})}async function T(e,a,r){try{return (await e.send(new GetObjectAclCommand({Bucket:a,Key:r}))).Grants?.some(s=>s.Grantee?.URI==="http://acs.amazonaws.com/groups/global/AllUsers"&&(s.Permission==="READ"||s.Permission==="FULL_CONTROL"))?"public-read":"private"}catch{return "private"}}function j(e){return d(async a=>{let r=await y(a);if(!r)return Response.json({message:"Invalid JSON payload"},{status:400});let t=m(r.key,"key");if(t instanceof Response)return t;let n=r.bucket?.trim()||e.defaultBucket,s=await l(e.upload?.confirmGuard,{request:a,key:t,bucket:n});if(s)return s;let o=await e.s3.send(new HeadObjectCommand({Bucket:n,Key:t})),i=await T(e.s3,n,t),p=h(e.resolvePublicUrl,n,t,i),c=I(o.ContentDisposition),u={request:a,key:t,bucket:n,contentType:o.ContentType,contentLength:o.ContentLength??0,eTag:o.ETag?.replace(/"/g,""),metadata:o.Metadata,acl:i,fileName:c,publicUrl:p,versionId:o.VersionId,lastModified:o.LastModified?.toISOString()};return await e.upload?.onUploadConfirmed?.(u),Response.json({key:t,bucket:n,contentType:u.contentType,contentLength:u.contentLength,eTag:u.eTag,metadata:u.metadata??{},acl:i,fileName:c,publicUrl:p,versionId:u.versionId,lastModified:u.lastModified})})}function E(e){return d(async a=>{let{searchParams:r}=new URL(a.url),t=r.get("key")?.trim();if(!t)return Response.json({message:"key query parameter is required"},{status:400});let n=r.get("bucket")?.trim()||e.defaultBucket,s=w(r.get("expiresIn")),o=r.get("fileName")?.trim(),i=await l(e.download?.presignGuard,{request:a,key:t,bucket:n,fileName:o||void 0});if(i)return i;try{await e.s3.send(new HeadObjectCommand({Bucket:n,Key:t}));}catch(c){let u=c?.name;if(u==="NoSuchKey"||u==="NotFound")return Response.json({message:"Object not found"},{status:404});throw c}let p=await getSignedUrl(e.s3,new GetObjectCommand({Bucket:n,Key:t,ResponseContentDisposition:o?b(o):"attachment"}),{expiresIn:s});return await e.download?.onPresigned?.({request:a,key:t,bucket:n,fileName:o||void 0,url:p,expiresIn:s}),Response.json({bucket:n,key:t,url:p,expiresIn:s})})}function z(e){return d(async a=>{let{searchParams:r}=new URL(a.url),t=r.get("key")?.trim();if(!t)return Response.json({message:"key query parameter is required"},{status:400});let n=r.get("bucket")?.trim()||e.defaultBucket,s=await l(e.delete?.deleteGuard,{request:a,key:t,bucket:n});if(s)return s;try{await e.s3.send(new HeadObjectCommand({Bucket:n,Key:t}));}catch{return Response.json({message:"Object not found"},{status:404})}return await e.s3.send(new DeleteObjectCommand({Bucket:n,Key:t})),await e.delete?.onDeleted?.({request:a,key:t,bucket:n}),Response.json({success:!0,bucket:n,key:t})})}function O(e){return d(async a=>{let r=await y(a);if(!r)return Response.json({message:"Invalid JSON payload"},{status:400});let t=m(r.key,"key");if(t instanceof Response)return t;let n=r.bucket?.trim()||e.defaultBucket,s=r.acl==="public-read"?"public-read":"private",o=typeof r.fileSize=="number"&&r.fileSize>0?Math.floor(r.fileSize):void 0;if(e.multipart?.requireFileSize&&o===void 0)return Response.json({message:"fileSize is required when multipart.requireFileSize is enabled"},{status:400});let i=await l(e.multipart?.initGuard,{request:a,key:t,bucket:n,fileSize:o});if(i)return i;let{UploadId:p}=await e.s3.send(new CreateMultipartUploadCommand({Bucket:n,Key:t,ContentType:r.contentType,ContentDisposition:r.fileName?b(r.fileName):void 0,Metadata:r.metadata,ACL:s}));return await e.multipart?.onInit?.({request:a,key:t,bucket:n,uploadId:p,contentType:r.contentType,fileSize:o,metadata:r.metadata,acl:s}),Response.json({bucket:n,key:t,uploadId:p},{status:201})})}function v(e){return d(async a=>{let r=await y(a);if(!r)return Response.json({message:"Invalid JSON payload"},{status:400});let t=m(r.key,"key");if(t instanceof Response)return t;let n=m(r.uploadId,"uploadId");if(n instanceof Response)return n;let s=Number(r.partNumber);if(!Number.isInteger(s)||s<=0)return Response.json({message:"partNumber must be a positive integer"},{status:400});let o=r.bucket?.trim()||e.defaultBucket,i=w(r.expiresIn),p=typeof r.partSize=="number"&&r.partSize>0?Math.floor(r.partSize):null,c=await l(e.multipart?.partGuard,{request:a,key:t,bucket:o});if(c)return c;let u=await getSignedUrl(e.s3,new UploadPartCommand({Bucket:o,Key:t,UploadId:n,PartNumber:s,...p!==null?{ContentLength:p}:{}}),{expiresIn:i,...p!==null?{signableHeaders:new Set(["content-length"])}:{}});return Response.json({presignedUrl:u,partNumber:s,uploadId:n,bucket:o,expiresIn:i,...p!==null?{partSize:p}:{}})})}function A(e){return d(async a=>{let r=await y(a);if(!r)return Response.json({message:"Invalid JSON payload"},{status:400});let t=m(r.key,"key");if(t instanceof Response)return t;let n=m(r.uploadId,"uploadId");if(n instanceof Response)return n;let s=(Array.isArray(r.parts)?r.parts:[]).map(({partNumber:g})=>Number(g)).filter(g=>Number.isInteger(g)&&g>0).sort((g,H)=>g-H);if(!s.length)return Response.json({message:"At least one valid part is required"},{status:400});let o=r.bucket?.trim()||e.defaultBucket,i=await l(e.multipart?.completeGuard,{request:a,key:t,bucket:o});if(i)return i;let c=(await e.s3.send(new ListPartsCommand({Bucket:o,Key:t,UploadId:n}))).Parts??[],u=s.map(g=>{let H=c.find(q=>q.PartNumber===g);return {PartNumber:g,ETag:H?.ETag??""}}),C=await e.s3.send(new CompleteMultipartUploadCommand({Bucket:o,Key:t,UploadId:n,MultipartUpload:{Parts:u}})),f=await e.s3.send(new HeadObjectCommand({Bucket:o,Key:t}));for(let g=0;g<4&&!f.ContentLength;g++)await new Promise(H=>setTimeout(H,250*2**g)),f=await e.s3.send(new HeadObjectCommand({Bucket:o,Key:t}));let P=f.ContentLength??0,x=f.ContentType,N=(f.ETag??C.ETag??"").replace(/"/g,""),R=f.Metadata??{},S=f.VersionId,L=f.LastModified?.toISOString(),U=await T(e.s3,o,t),D=h(e.resolvePublicUrl,o,t,U),$=I(f.ContentDisposition);return await e.multipart?.onComplete?.({request:a,key:t,bucket:o,uploadId:n,contentLength:P,contentType:x,eTag:N,metadata:R,acl:U,fileName:$,publicUrl:D,versionId:S,lastModified:L}),Response.json({bucket:o,key:t,uploadId:n,contentLength:P,contentType:x,eTag:N,metadata:R,acl:U,fileName:$,publicUrl:D,versionId:S,lastModified:L})})}function B(e){return d(async a=>{let r=await y(a);if(!r)return Response.json({message:"Invalid JSON payload"},{status:400});let t=m(r.key,"key");if(t instanceof Response)return t;let n=m(r.uploadId,"uploadId");if(n instanceof Response)return n;let s=r.bucket?.trim()||e.defaultBucket,o=await l(e.multipart?.abortGuard,{request:a,key:t,bucket:s});return o||(await e.s3.send(new AbortMultipartUploadCommand({Bucket:s,Key:t,UploadId:n})),await e.multipart?.onAbort?.({request:a,key:t,bucket:s,uploadId:n}),Response.json({bucket:s,key:t,uploadId:n,aborted:!0}))})}function F(e){return {presign:{upload:M(e),confirm:j(e),download:E(e)},multipart:{init:O(e),part:v(e),complete:A(e),abort:B(e)},delete:z(e)}}var k=()=>Response.json({message:"Not Found"},{status:404});function pe(e){let a=F(e),r=e.basePath.replace(/\/$/,"");return async t=>{let n=await l(e.guard,{request:t});if(n)return n;let o=new URL(t.url).pathname.slice(r.length).replace(/^\//,""),i=t.method;return i==="POST"&&o==="presign/upload"?e.upload?.enabled?a.presign.upload(t):k():i==="POST"&&o==="presign/upload/confirm"?e.upload?.enabled?a.presign.confirm(t):k():i==="GET"&&o==="presign/download"?e.download?.enabled?a.presign.download(t):k():i==="DELETE"&&o==="delete"?e.delete?.enabled?a.delete(t):k():i==="POST"&&o==="presign/multipart/init"?e.multipart?.enabled?a.multipart.init(t):k():i==="POST"&&o==="presign/multipart/part"?e.multipart?.enabled?a.multipart.part(t):k():i==="POST"&&o==="presign/multipart/complete"?e.multipart?.enabled?a.multipart.complete(t):k():i==="POST"&&o==="presign/multipart/abort"?e.multipart?.enabled?a.multipart.abort(t):k():Response.json({message:"Not Found"},{status:404})}}function G(e="/api/s3"){let a=e.replace(/\/$/,""),r=async(n,s)=>{let o=await fetch(n,s);if(!o.ok){let i=await o.json().catch(()=>({}));throw new Error(i.message??o.statusText)}return o.json()},t=(n,s)=>r(n,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)});return {upload(n){return t(`${a}/presign/upload`,n)},confirm(n){return t(`${a}/presign/upload/confirm`,n)},download(n,s){let o=new URLSearchParams({key:n});if(s?.fileName){let i=s.fileName.replace(/["\\\r\n]/g,"_");o.set("fileName",i);}return s?.bucket&&o.set("bucket",s.bucket),r(`${a}/presign/download?${o}`)},delete(n,s){let o=new URLSearchParams({key:n});return s?.bucket&&o.set("bucket",s.bucket),r(`${a}/delete?${o}`,{method:"DELETE"})},multipart:{init(n){return t(`${a}/presign/multipart/init`,n)},signPart(n){return t(`${a}/presign/multipart/part`,n)},complete(n){return t(`${a}/presign/multipart/complete`,n)},abort(n){return t(`${a}/presign/multipart/abort`,n)}}}}function le(e,a){if(a.accept?.length&&!a.accept.some(t=>t.startsWith(".")?e.name.toLowerCase().endsWith(t.toLowerCase()):t.endsWith("/*")?e.type.startsWith(t.replace("/*","/")):e.type===t)){let t=e.name.includes(".")?e.name.split(".").pop():null;return `File type "${t?`.${t}`:e.type||"unknown"}" is not allowed`}return e.size===0?"File is empty":a.maxFileSize&&e.size>a.maxFileSize?`File size exceeds ${(a.maxFileSize/1048576).toFixed(1)} MB limit`:null}
2
- export{j as createConfirmHandler,z as createDeleteHandler,E as createDownloadHandler,F as createHandlers,B as createMultipartAbortHandler,A as createMultipartCompleteHandler,O as createMultipartInitHandler,v as createMultipartPartHandler,G as createPresignApi,pe as createRouter,G as createS3Api,M as createUploadHandler,le as validateFile};//# sourceMappingURL=index.js.map
1
+ import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
2
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
3
+ import { PutObjectCommand, HeadObjectCommand, GetObjectCommand, DeleteObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, ListPartsCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, GetObjectAclCommand } from '@aws-sdk/client-s3';
4
+
5
+ // src/handlers/presign/upload.ts
6
+
7
+ // src/internal-helpers.ts
8
+ async function parseBody(request) {
9
+ try {
10
+ const body = await request.json();
11
+ return body && typeof body === "object" ? body : null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+ function requireString(value, name) {
17
+ const trimmed = typeof value === "string" ? value.trim() : "";
18
+ if (!trimmed) {
19
+ return Response.json({ message: `${name} is required` }, { status: 400 });
20
+ }
21
+ return trimmed;
22
+ }
23
+ function normalizeExpiresIn(value) {
24
+ const n = Number(value);
25
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 600;
26
+ }
27
+ function withS3ErrorHandler(handler) {
28
+ return async (request) => {
29
+ try {
30
+ return await handler(request);
31
+ } catch (err) {
32
+ const code = err.code;
33
+ const isNetworkError = code === "EAI_AGAIN" || code === "ECONNREFUSED" || code === "ECONNRESET" || code === "ETIMEDOUT" || code === "ENOTFOUND";
34
+ const message = isNetworkError ? `S3 endpoint unreachable (${code}): check your endpoint URL and network connectivity` : err instanceof Error ? err.message : "Internal server error";
35
+ console.error("[S3 API]", message);
36
+ return Response.json({ message }, { status: 502 });
37
+ }
38
+ };
39
+ }
40
+ async function runHook(hook, context) {
41
+ if (!hook) return null;
42
+ try {
43
+ await hook(context);
44
+ return null;
45
+ } catch (err) {
46
+ const message = err instanceof Error ? err.message : "Forbidden";
47
+ const status = typeof err?.status === "number" ? err.status : 403;
48
+ return Response.json({ message }, { status });
49
+ }
50
+ }
51
+
52
+ // src/helpers/build-content-disposition.ts
53
+ function buildContentDisposition(fileName) {
54
+ const ascii = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_");
55
+ const encoded = encodeURIComponent(fileName);
56
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
57
+ }
58
+
59
+ // src/helpers/build-public-url.ts
60
+ function buildPublicUrl(publicUrlBase, bucket, key, acl) {
61
+ if (acl !== "public-read" || !publicUrlBase) return void 0;
62
+ const base = publicUrlBase(bucket);
63
+ if (!base) return void 0;
64
+ return `${base.replace(/\/$/, "")}/${key}`;
65
+ }
66
+
67
+ // src/helpers/parse-file-name.ts
68
+ function parseFileName(contentDisposition) {
69
+ if (!contentDisposition) return void 0;
70
+ const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;\s]+)/i);
71
+ if (utf8Match) {
72
+ try {
73
+ return decodeURIComponent(utf8Match[1]);
74
+ } catch {
75
+ }
76
+ }
77
+ const asciiMatch = contentDisposition.match(/filename="([^"]*)"/i);
78
+ return asciiMatch?.[1];
79
+ }
80
+
81
+ // src/handlers/presign/upload.ts
82
+ function createUploadHandler(config) {
83
+ return withS3ErrorHandler(async (request) => {
84
+ const body = await parseBody(request);
85
+ if (!body) {
86
+ return Response.json(
87
+ { message: "Invalid JSON payload" },
88
+ { status: 400 }
89
+ );
90
+ }
91
+ const key = requireString(body.key, "key");
92
+ if (key instanceof Response) return key;
93
+ const bucket = body.bucket?.trim() || config.defaultBucket;
94
+ const expiresIn = normalizeExpiresIn(body.expiresIn);
95
+ const acl = body.acl === "public-read" ? "public-read" : "private";
96
+ const contentType = body.contentType?.trim() || "application/octet-stream";
97
+ const fileSize = typeof body.fileSize === "number" && body.fileSize > 0 ? Math.floor(body.fileSize) : null;
98
+ const guardResult = await runHook(config.upload?.presignGuard, {
99
+ request,
100
+ key,
101
+ bucket,
102
+ contentType: body.contentType,
103
+ fileSize: fileSize ?? void 0,
104
+ metadata: body.metadata,
105
+ acl
106
+ });
107
+ if (guardResult) return guardResult;
108
+ const method = config.upload?.method ?? "post";
109
+ if (method === "put" && config.upload?.requireFileSize && fileSize === null) {
110
+ return Response.json(
111
+ {
112
+ message: "fileSize is required when upload.requireFileSize is enabled"
113
+ },
114
+ { status: 400 }
115
+ );
116
+ }
117
+ if (method === "put") {
118
+ const putHeaders = {
119
+ "Content-Type": contentType
120
+ };
121
+ if (body.fileName) {
122
+ putHeaders["Content-Disposition"] = buildContentDisposition(
123
+ body.fileName
124
+ );
125
+ }
126
+ const url2 = await getSignedUrl(
127
+ config.s3,
128
+ new PutObjectCommand({
129
+ Bucket: bucket,
130
+ Key: key,
131
+ ContentType: contentType,
132
+ ACL: acl,
133
+ ...body.fileName ? { ContentDisposition: buildContentDisposition(body.fileName) } : {},
134
+ ...fileSize !== null ? { ContentLength: fileSize } : {}
135
+ }),
136
+ {
137
+ expiresIn,
138
+ // Force content-length into X-Amz-SignedHeaders. Without this the
139
+ // SDK omits it from the signed header set and size enforcement is lost.
140
+ ...fileSize !== null ? { signableHeaders: /* @__PURE__ */ new Set(["content-length"]) } : {}
141
+ }
142
+ );
143
+ await config.upload?.onPresigned?.({
144
+ request,
145
+ key,
146
+ bucket,
147
+ contentType: body.contentType,
148
+ metadata: body.metadata,
149
+ acl,
150
+ url: url2,
151
+ expiresIn
152
+ });
153
+ return Response.json({
154
+ bucket,
155
+ key,
156
+ url: url2,
157
+ headers: putHeaders,
158
+ expiresIn,
159
+ method: "put"
160
+ });
161
+ }
162
+ const fields = { acl, "Content-Type": contentType };
163
+ if (body.fileName) {
164
+ fields["Content-Disposition"] = buildContentDisposition(body.fileName);
165
+ }
166
+ if (body.metadata) {
167
+ for (const [k, v] of Object.entries(body.metadata)) {
168
+ fields[`x-amz-meta-${k}`] = v;
169
+ }
170
+ }
171
+ const rangeMin = fileSize ?? 1;
172
+ const rangeMax = fileSize ?? void 0;
173
+ const { url, fields: signedFields } = await createPresignedPost(config.s3, {
174
+ Bucket: bucket,
175
+ Key: key,
176
+ Conditions: rangeMax !== void 0 ? [["content-length-range", rangeMin, rangeMax]] : [["content-length-range", rangeMin, Number.MAX_SAFE_INTEGER]],
177
+ Fields: fields,
178
+ Expires: expiresIn
179
+ });
180
+ await config.upload?.onPresigned?.({
181
+ request,
182
+ key,
183
+ bucket,
184
+ contentType: body.contentType,
185
+ metadata: body.metadata,
186
+ acl,
187
+ url,
188
+ expiresIn
189
+ });
190
+ return Response.json({
191
+ bucket,
192
+ key,
193
+ url,
194
+ fields: signedFields,
195
+ expiresIn,
196
+ method: "post"
197
+ });
198
+ });
199
+ }
200
+ async function resolveObjectAcl(s3, bucket, key) {
201
+ try {
202
+ const result = await s3.send(
203
+ new GetObjectAclCommand({ Bucket: bucket, Key: key })
204
+ );
205
+ const isPublic = result.Grants?.some(
206
+ (g) => g.Grantee?.URI === "http://acs.amazonaws.com/groups/global/AllUsers" && (g.Permission === "READ" || g.Permission === "FULL_CONTROL")
207
+ );
208
+ return isPublic ? "public-read" : "private";
209
+ } catch {
210
+ return "private";
211
+ }
212
+ }
213
+
214
+ // src/handlers/presign/confirm.ts
215
+ function createConfirmHandler(config) {
216
+ return withS3ErrorHandler(async (request) => {
217
+ const body = await parseBody(request);
218
+ if (!body) {
219
+ return Response.json(
220
+ { message: "Invalid JSON payload" },
221
+ { status: 400 }
222
+ );
223
+ }
224
+ const key = requireString(body.key, "key");
225
+ if (key instanceof Response) return key;
226
+ const bucket = body.bucket?.trim() || config.defaultBucket;
227
+ const guardResult = await runHook(config.upload?.confirmGuard, {
228
+ request,
229
+ key,
230
+ bucket
231
+ });
232
+ if (guardResult) return guardResult;
233
+ const head = await config.s3.send(
234
+ new HeadObjectCommand({ Bucket: bucket, Key: key })
235
+ );
236
+ const acl = await resolveObjectAcl(config.s3, bucket, key);
237
+ const publicUrl = buildPublicUrl(config.resolvePublicUrl, bucket, key, acl);
238
+ const fileName = parseFileName(head.ContentDisposition);
239
+ const context = {
240
+ request,
241
+ key,
242
+ bucket,
243
+ contentType: head.ContentType,
244
+ contentLength: head.ContentLength ?? 0,
245
+ eTag: head.ETag?.replace(/"/g, ""),
246
+ metadata: head.Metadata,
247
+ acl,
248
+ fileName,
249
+ publicUrl,
250
+ versionId: head.VersionId,
251
+ lastModified: head.LastModified?.toISOString()
252
+ };
253
+ await config.upload?.onUploadConfirmed?.(context);
254
+ return Response.json({
255
+ key,
256
+ bucket,
257
+ contentType: context.contentType,
258
+ contentLength: context.contentLength,
259
+ eTag: context.eTag,
260
+ metadata: context.metadata ?? {},
261
+ acl,
262
+ fileName,
263
+ publicUrl,
264
+ versionId: context.versionId,
265
+ lastModified: context.lastModified
266
+ });
267
+ });
268
+ }
269
+ function createDownloadHandler(config) {
270
+ return withS3ErrorHandler(async (request) => {
271
+ const { searchParams } = new URL(request.url);
272
+ const key = searchParams.get("key")?.trim();
273
+ if (!key) {
274
+ return Response.json(
275
+ { message: "key query parameter is required" },
276
+ { status: 400 }
277
+ );
278
+ }
279
+ const bucket = searchParams.get("bucket")?.trim() || config.defaultBucket;
280
+ const expiresIn = normalizeExpiresIn(searchParams.get("expiresIn"));
281
+ const fileName = searchParams.get("fileName")?.trim();
282
+ const guardResult = await runHook(config.download?.presignGuard, {
283
+ request,
284
+ key,
285
+ bucket,
286
+ fileName: fileName || void 0
287
+ });
288
+ if (guardResult) return guardResult;
289
+ try {
290
+ await config.s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
291
+ } catch (err) {
292
+ const name = err?.name;
293
+ if (name === "NoSuchKey" || name === "NotFound") {
294
+ return Response.json({ message: "Object not found" }, { status: 404 });
295
+ }
296
+ throw err;
297
+ }
298
+ const url = await getSignedUrl(
299
+ config.s3,
300
+ new GetObjectCommand({
301
+ Bucket: bucket,
302
+ Key: key,
303
+ ResponseContentDisposition: fileName ? buildContentDisposition(fileName) : "attachment"
304
+ }),
305
+ { expiresIn }
306
+ );
307
+ await config.download?.onPresigned?.({
308
+ request,
309
+ key,
310
+ bucket,
311
+ fileName: fileName || void 0,
312
+ url,
313
+ expiresIn
314
+ });
315
+ return Response.json({ bucket, key, url, expiresIn });
316
+ });
317
+ }
318
+ function createDeleteHandler(config) {
319
+ return withS3ErrorHandler(async (request) => {
320
+ const { searchParams } = new URL(request.url);
321
+ const key = searchParams.get("key")?.trim();
322
+ if (!key) {
323
+ return Response.json(
324
+ { message: "key query parameter is required" },
325
+ { status: 400 }
326
+ );
327
+ }
328
+ const bucket = searchParams.get("bucket")?.trim() || config.defaultBucket;
329
+ const guardResult = await runHook(config.delete?.deleteGuard, {
330
+ request,
331
+ key,
332
+ bucket
333
+ });
334
+ if (guardResult) return guardResult;
335
+ try {
336
+ await config.s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
337
+ } catch {
338
+ return Response.json({ message: "Object not found" }, { status: 404 });
339
+ }
340
+ await config.s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
341
+ await config.delete?.onDeleted?.({ request, key, bucket });
342
+ return Response.json({ success: true, bucket, key });
343
+ });
344
+ }
345
+ function createMultipartInitHandler(config) {
346
+ return withS3ErrorHandler(async (request) => {
347
+ const body = await parseBody(request);
348
+ if (!body) {
349
+ return Response.json(
350
+ { message: "Invalid JSON payload" },
351
+ { status: 400 }
352
+ );
353
+ }
354
+ const key = requireString(body.key, "key");
355
+ if (key instanceof Response) return key;
356
+ const bucket = body.bucket?.trim() || config.defaultBucket;
357
+ const acl = body.acl === "public-read" ? "public-read" : "private";
358
+ const fileSize = typeof body.fileSize === "number" && body.fileSize > 0 ? Math.floor(body.fileSize) : void 0;
359
+ if (config.multipart?.requireFileSize && fileSize === void 0) {
360
+ return Response.json(
361
+ {
362
+ message: "fileSize is required when multipart.requireFileSize is enabled"
363
+ },
364
+ { status: 400 }
365
+ );
366
+ }
367
+ const guardResult = await runHook(config.multipart?.initGuard, {
368
+ request,
369
+ key,
370
+ bucket,
371
+ fileSize
372
+ });
373
+ if (guardResult) return guardResult;
374
+ const { UploadId } = await config.s3.send(
375
+ new CreateMultipartUploadCommand({
376
+ Bucket: bucket,
377
+ Key: key,
378
+ ContentType: body.contentType,
379
+ ContentDisposition: body.fileName ? buildContentDisposition(body.fileName) : void 0,
380
+ Metadata: body.metadata,
381
+ ACL: acl
382
+ })
383
+ );
384
+ await config.multipart?.onInit?.({
385
+ request,
386
+ key,
387
+ bucket,
388
+ uploadId: UploadId,
389
+ contentType: body.contentType,
390
+ fileSize,
391
+ metadata: body.metadata,
392
+ acl
393
+ });
394
+ return Response.json({ bucket, key, uploadId: UploadId }, { status: 201 });
395
+ });
396
+ }
397
+ function createMultipartPartHandler(config) {
398
+ return withS3ErrorHandler(async (request) => {
399
+ const body = await parseBody(request);
400
+ if (!body) {
401
+ return Response.json(
402
+ { message: "Invalid JSON payload" },
403
+ { status: 400 }
404
+ );
405
+ }
406
+ const key = requireString(body.key, "key");
407
+ if (key instanceof Response) return key;
408
+ const uploadId = requireString(body.uploadId, "uploadId");
409
+ if (uploadId instanceof Response) return uploadId;
410
+ const partNumber = Number(body.partNumber);
411
+ if (!Number.isInteger(partNumber) || partNumber <= 0) {
412
+ return Response.json(
413
+ { message: "partNumber must be a positive integer" },
414
+ { status: 400 }
415
+ );
416
+ }
417
+ const bucket = body.bucket?.trim() || config.defaultBucket;
418
+ const expiresIn = normalizeExpiresIn(body.expiresIn);
419
+ const partSize = typeof body.partSize === "number" && body.partSize > 0 ? Math.floor(body.partSize) : null;
420
+ const guardResult = await runHook(config.multipart?.partGuard, {
421
+ request,
422
+ key,
423
+ bucket
424
+ });
425
+ if (guardResult) return guardResult;
426
+ const presignedUrl = await getSignedUrl(
427
+ config.s3,
428
+ new UploadPartCommand({
429
+ Bucket: bucket,
430
+ Key: key,
431
+ UploadId: uploadId,
432
+ PartNumber: partNumber,
433
+ ...partSize !== null ? { ContentLength: partSize } : {}
434
+ }),
435
+ {
436
+ expiresIn,
437
+ ...partSize !== null ? { signableHeaders: /* @__PURE__ */ new Set(["content-length"]) } : {}
438
+ }
439
+ );
440
+ return Response.json({
441
+ presignedUrl,
442
+ partNumber,
443
+ uploadId,
444
+ bucket,
445
+ expiresIn,
446
+ ...partSize !== null ? { partSize } : {}
447
+ });
448
+ });
449
+ }
450
+ function createMultipartCompleteHandler(config) {
451
+ return withS3ErrorHandler(async (request) => {
452
+ const body = await parseBody(request);
453
+ if (!body) {
454
+ return Response.json(
455
+ { message: "Invalid JSON payload" },
456
+ { status: 400 }
457
+ );
458
+ }
459
+ const key = requireString(body.key, "key");
460
+ if (key instanceof Response) return key;
461
+ const uploadId = requireString(body.uploadId, "uploadId");
462
+ if (uploadId instanceof Response) return uploadId;
463
+ const parts = (Array.isArray(body.parts) ? body.parts : []).map(({ partNumber }) => Number(partNumber)).filter((n) => Number.isInteger(n) && n > 0).sort((a, b) => a - b);
464
+ if (!parts.length) {
465
+ return Response.json(
466
+ { message: "At least one valid part is required" },
467
+ { status: 400 }
468
+ );
469
+ }
470
+ const bucket = body.bucket?.trim() || config.defaultBucket;
471
+ const guardResult = await runHook(config.multipart?.completeGuard, {
472
+ request,
473
+ key,
474
+ bucket
475
+ });
476
+ if (guardResult) return guardResult;
477
+ const listed = await config.s3.send(
478
+ new ListPartsCommand({ Bucket: bucket, Key: key, UploadId: uploadId })
479
+ );
480
+ const listedParts = listed.Parts ?? [];
481
+ const completeParts = parts.map((partNumber) => {
482
+ const found = listedParts.find((p) => p.PartNumber === partNumber);
483
+ return { PartNumber: partNumber, ETag: found?.ETag ?? "" };
484
+ });
485
+ const completeResult = await config.s3.send(
486
+ new CompleteMultipartUploadCommand({
487
+ Bucket: bucket,
488
+ Key: key,
489
+ UploadId: uploadId,
490
+ MultipartUpload: { Parts: completeParts }
491
+ })
492
+ );
493
+ let head = await config.s3.send(
494
+ new HeadObjectCommand({ Bucket: bucket, Key: key })
495
+ );
496
+ for (let attempt = 0; attempt < 4 && !head.ContentLength; attempt++) {
497
+ await new Promise((r) => setTimeout(r, 250 * 2 ** attempt));
498
+ head = await config.s3.send(
499
+ new HeadObjectCommand({ Bucket: bucket, Key: key })
500
+ );
501
+ }
502
+ const contentLength = head.ContentLength ?? 0;
503
+ const contentType = head.ContentType;
504
+ const eTag = (head.ETag ?? completeResult.ETag ?? "").replace(/"/g, "");
505
+ const metadata = head.Metadata ?? {};
506
+ const versionId = head.VersionId;
507
+ const lastModified = head.LastModified?.toISOString();
508
+ const acl = await resolveObjectAcl(config.s3, bucket, key);
509
+ const publicUrl = buildPublicUrl(config.resolvePublicUrl, bucket, key, acl);
510
+ const fileName = parseFileName(head.ContentDisposition);
511
+ await config.multipart?.onComplete?.({
512
+ request,
513
+ key,
514
+ bucket,
515
+ uploadId,
516
+ contentLength,
517
+ contentType,
518
+ eTag,
519
+ metadata,
520
+ acl,
521
+ fileName,
522
+ publicUrl,
523
+ versionId,
524
+ lastModified
525
+ });
526
+ return Response.json({
527
+ bucket,
528
+ key,
529
+ uploadId,
530
+ contentLength,
531
+ contentType,
532
+ eTag,
533
+ metadata,
534
+ acl,
535
+ fileName,
536
+ publicUrl,
537
+ versionId,
538
+ lastModified
539
+ });
540
+ });
541
+ }
542
+ function createMultipartAbortHandler(config) {
543
+ return withS3ErrorHandler(async (request) => {
544
+ const body = await parseBody(request);
545
+ if (!body) {
546
+ return Response.json(
547
+ { message: "Invalid JSON payload" },
548
+ { status: 400 }
549
+ );
550
+ }
551
+ const key = requireString(body.key, "key");
552
+ if (key instanceof Response) return key;
553
+ const uploadId = requireString(body.uploadId, "uploadId");
554
+ if (uploadId instanceof Response) return uploadId;
555
+ const bucket = body.bucket?.trim() || config.defaultBucket;
556
+ const guardResult = await runHook(config.multipart?.abortGuard, {
557
+ request,
558
+ key,
559
+ bucket
560
+ });
561
+ if (guardResult) return guardResult;
562
+ await config.s3.send(
563
+ new AbortMultipartUploadCommand({
564
+ Bucket: bucket,
565
+ Key: key,
566
+ UploadId: uploadId
567
+ })
568
+ );
569
+ await config.multipart?.onAbort?.({
570
+ request,
571
+ key,
572
+ bucket,
573
+ uploadId
574
+ });
575
+ return Response.json({ bucket, key, uploadId, aborted: true });
576
+ });
577
+ }
578
+
579
+ // src/create-handlers.ts
580
+ function createHandlers(config) {
581
+ return {
582
+ presign: {
583
+ upload: createUploadHandler(config),
584
+ confirm: createConfirmHandler(config),
585
+ download: createDownloadHandler(config)
586
+ },
587
+ multipart: {
588
+ init: createMultipartInitHandler(config),
589
+ part: createMultipartPartHandler(config),
590
+ complete: createMultipartCompleteHandler(config),
591
+ abort: createMultipartAbortHandler(config)
592
+ },
593
+ delete: createDeleteHandler(config)
594
+ };
595
+ }
596
+
597
+ // src/router.ts
598
+ var disabled = () => Response.json({ message: "Not Found" }, { status: 404 });
599
+ function createRouter(config) {
600
+ const handlers = createHandlers(config);
601
+ const base = config.basePath.replace(/\/$/, "");
602
+ return async (request) => {
603
+ const guardResult = await runHook(config.guard, { request });
604
+ if (guardResult) return guardResult;
605
+ const url = new URL(request.url);
606
+ const subpath = url.pathname.slice(base.length).replace(/^\//, "");
607
+ const method = request.method;
608
+ if (method === "POST" && subpath === "presign/upload")
609
+ return config.upload?.enabled ? handlers.presign.upload(request) : disabled();
610
+ if (method === "POST" && subpath === "presign/upload/confirm")
611
+ return config.upload?.enabled ? handlers.presign.confirm(request) : disabled();
612
+ if (method === "GET" && subpath === "presign/download")
613
+ return config.download?.enabled ? handlers.presign.download(request) : disabled();
614
+ if (method === "DELETE" && subpath === "delete")
615
+ return config.delete?.enabled ? handlers.delete(request) : disabled();
616
+ if (method === "POST" && subpath === "presign/multipart/init")
617
+ return config.multipart?.enabled ? handlers.multipart.init(request) : disabled();
618
+ if (method === "POST" && subpath === "presign/multipart/part")
619
+ return config.multipart?.enabled ? handlers.multipart.part(request) : disabled();
620
+ if (method === "POST" && subpath === "presign/multipart/complete")
621
+ return config.multipart?.enabled ? handlers.multipart.complete(request) : disabled();
622
+ if (method === "POST" && subpath === "presign/multipart/abort")
623
+ return config.multipart?.enabled ? handlers.multipart.abort(request) : disabled();
624
+ return Response.json({ message: "Not Found" }, { status: 404 });
625
+ };
626
+ }
627
+
628
+ // src/api.ts
629
+ function createS3Api(basePath = "/api/s3") {
630
+ const base = basePath.replace(/\/$/, "");
631
+ const json = async (url, init) => {
632
+ const res = await fetch(url, init);
633
+ if (!res.ok) {
634
+ const body = await res.json().catch(() => ({}));
635
+ throw new Error(body.message ?? res.statusText);
636
+ }
637
+ return res.json();
638
+ };
639
+ const post = (url, body) => json(url, {
640
+ method: "POST",
641
+ headers: { "Content-Type": "application/json" },
642
+ body: JSON.stringify(body)
643
+ });
644
+ return {
645
+ upload(payload) {
646
+ return post(`${base}/presign/upload`, payload);
647
+ },
648
+ confirm(payload) {
649
+ return post(
650
+ `${base}/presign/upload/confirm`,
651
+ payload
652
+ );
653
+ },
654
+ download(key, options) {
655
+ const params = new URLSearchParams({ key });
656
+ if (options?.fileName) {
657
+ const safe = options.fileName.replace(/["\\\r\n]/g, "_");
658
+ params.set("fileName", safe);
659
+ }
660
+ if (options?.bucket) params.set("bucket", options.bucket);
661
+ return json(`${base}/presign/download?${params}`);
662
+ },
663
+ delete(key, options) {
664
+ const params = new URLSearchParams({ key });
665
+ if (options?.bucket) params.set("bucket", options.bucket);
666
+ return json(
667
+ `${base}/delete?${params}`,
668
+ { method: "DELETE" }
669
+ );
670
+ },
671
+ multipart: {
672
+ init(payload) {
673
+ return post(
674
+ `${base}/presign/multipart/init`,
675
+ payload
676
+ );
677
+ },
678
+ signPart(payload) {
679
+ return post(
680
+ `${base}/presign/multipart/part`,
681
+ payload
682
+ );
683
+ },
684
+ complete(payload) {
685
+ return post(`${base}/presign/multipart/complete`, payload);
686
+ },
687
+ abort(payload) {
688
+ return post(
689
+ `${base}/presign/multipart/abort`,
690
+ payload
691
+ );
692
+ }
693
+ }
694
+ };
695
+ }
696
+
697
+ // src/helpers/validate-file.ts
698
+ function validateFile(file, options) {
699
+ if (options.accept?.length) {
700
+ const allowed = options.accept.some((type) => {
701
+ if (type.startsWith(".")) {
702
+ return file.name.toLowerCase().endsWith(type.toLowerCase());
703
+ }
704
+ if (type.endsWith("/*")) {
705
+ return file.type.startsWith(type.replace("/*", "/"));
706
+ }
707
+ return file.type === type;
708
+ });
709
+ if (!allowed) {
710
+ const ext = file.name.includes(".") ? file.name.split(".").pop() : null;
711
+ return `File type "${ext ? `.${ext}` : file.type || "unknown"}" is not allowed`;
712
+ }
713
+ }
714
+ if (file.size === 0) {
715
+ return "File is empty";
716
+ }
717
+ if (options.maxFileSize && file.size > options.maxFileSize) {
718
+ const maxMB = (options.maxFileSize / (1024 * 1024)).toFixed(1);
719
+ return `File size exceeds ${maxMB} MB limit`;
720
+ }
721
+ return null;
722
+ }
723
+
724
+ export { createConfirmHandler, createDeleteHandler, createDownloadHandler, createHandlers, createMultipartAbortHandler, createMultipartCompleteHandler, createMultipartInitHandler, createMultipartPartHandler, createS3Api as createPresignApi, createRouter, createS3Api, createUploadHandler, validateFile };
725
+ //# sourceMappingURL=index.js.map
3
726
  //# sourceMappingURL=index.js.map