@better-s3/server 3.1045.1 → 3.1046.0

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