From 4383581b69cc612bdf02ee232662ee8e4de990ad Mon Sep 17 00:00:00 2001 From: Jolly Good <1671375+good-lly@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:13:44 +0100 Subject: [PATCH] #2 add/test browser support [wip] --- dev/TODO_index.html | 28 +++++--- dev/index.min.js | 19 ++++++ src/index.ts | 16 ++++- src/utils/crypto-wrapper.ts | 123 ++++++++++++++++++++++++++++++++++++ tsconfig.json | 6 +- 5 files changed, 180 insertions(+), 12 deletions(-) create mode 100644 dev/index.min.js create mode 100644 src/utils/crypto-wrapper.ts diff --git a/dev/TODO_index.html b/dev/TODO_index.html index aae9e57..35b6abc 100644 --- a/dev/TODO_index.html +++ b/dev/TODO_index.html @@ -3,19 +3,31 @@ - FemtoS3.js Dev Index File + ultralight-s3 - test browser

Welcome dummy

- - diff --git a/dev/index.min.js b/dev/index.min.js new file mode 100644 index 0000000..f76b94e --- /dev/null +++ b/dev/index.min.js @@ -0,0 +1,19 @@ +var T=crypto.createHmac,$=crypto.createHash;if(typeof T>"u"||typeof $>"u")try{if(typeof process<"u"&&process.versions!=null&&process.versions.node!=null){let e=await import("node:crypto");T=e.createHmac,$=e.createHash}else{let e=function(n){let r=n.reduce((a,c)=>a+c.length,0),o=new Uint8Array(r),i=0;for(let a of n)o.set(a,i),i+=a.length;return o},t=function(n,r){let o=Array.from(new Uint8Array(n));if(r==="hex")return o.map(i=>i.toString(16).padStart(2,"0")).join("");if(r==="base64"){let i=String.fromCharCode(...o);return btoa(i)}else{if(r==="latin1")return String.fromCharCode(...o);throw new Error(`Unsupported encoding: ${r}`)}};W=e,X=t,$=n=>{if(n!=="sha256")throw new Error("Only SHA-256 is supported in the browser.");let r=[];return{update(o){let i=new TextEncoder,a=typeof o=="string"?i.encode(o):new Uint8Array(o);return r.push(a),this},async digest(o="hex"){let i=e(r),a=await crypto.subtle.digest("SHA-256",i);return t(a,o)}}},T=(n,r)=>{if(n!=="sha256")throw new Error("Only SHA-256 HMAC is supported in the browser.");let o=[],i=new TextEncoder,a=typeof r=="string"?i.encode(r):new Uint8Array(r),c=null;function s(){return c||(c=crypto.subtle.importKey("raw",a,{name:"HMAC",hash:{name:"SHA-256"}},!1,["sign"])),c}return{update(l){let u=typeof l=="string"?i.encode(l):new Uint8Array(l);return o.push(u),this},async digest(l="hex"){let u=await s(),d=e(o),g=await crypto.subtle.sign("HMAC",u,d);return t(g,l)}}}}}catch{console.warn("ultralight-s3 Module: Crypto functions are not available. Using SubtleCrypto for browser compatibility.")}var W,X;var N="AWS4-HMAC-SHA256",I="aws4_request",C="s3",V="2",_="UNSIGNED-PAYLOAD",Z="application/octet-stream",K="application/xml",w="application/json",J=["accessKeyId","secretAccessKey","sessionToken","password"],B=5*1024*1024,f="x-amz-content-sha256",ee="x-amz-date",te="host",se="Authorization",m="Content-Type",R="Content-Length",L="etag",j="last-modified",h="ultralight-s3 Module: ",re=`${h}accessKeyId must be a non-empty string`,ne=`${h}secretAccessKey must be a non-empty string`,oe=`${h}endpoint must be a non-empty string`,ie=`${h}bucketName must be a non-empty string`,k=`${h}key must be a non-empty string`,b=`${h}uploadId must be a non-empty string`,F=`${h}parts must be a non-empty array`,G=`${h}Each part must have a partNumber (number) and ETag (string)`,D=`${h}data must be a Buffer or string`,z=`${h}prefix must be a string`,Q=`${h}maxKeys must be a positive integer`,v=`${h}delimiter must be a string`;typeof T>"u"&&typeof $>"u"&&console.error("ultralight-S3 Module: Crypto functions are not available, please report the issue with necessary description: https://github.com/sentienhq/ultralight-s3/issues");var ae={contents:!0},ce=p=>`%${p.charCodeAt(0).toString(16).toUpperCase()}`,O=p=>encodeURIComponent(p).replace(/[!'()*]/g,ce),y=p=>O(p).replace(/%2F/g,"/"),x=class{constructor({accessKeyId:e,secretAccessKey:t,endpoint:n,bucketName:r,region:o="auto",maxRequestSizeInBytes:i=B,requestAbortTimeout:a=void 0,logger:c=void 0}){this.getBucketName=()=>this.bucketName,this.setBucketName=s=>{this.bucketName=s},this.getRegion=()=>this.region,this.setRegion=s=>{this.region=s},this.getEndpoint=()=>this.endpoint,this.setEndpoint=s=>{this.endpoint=s},this.getMaxRequestSizeInBytes=()=>this.maxRequestSizeInBytes,this.setMaxRequestSizeInBytes=s=>{this.maxRequestSizeInBytes=s},this.sanitizeETag=s=>U(s),this.getProps=()=>({accessKeyId:this.accessKeyId,secretAccessKey:this.secretAccessKey,region:this.region,bucket:this.bucketName,endpoint:this.endpoint,maxRequestSizeInBytes:this.maxRequestSizeInBytes,requestAbortTimeout:this.requestAbortTimeout,logger:this.logger}),this.setProps=s=>{this._validateConstructorParams(s.accessKeyId,s.secretAccessKey,s.bucketName,s.endpoint),this.accessKeyId=s.accessKeyId,this.secretAccessKey=s.secretAccessKey,this.region=s.region||"auto",this.bucketName=s.bucketName,this.endpoint=s.endpoint,this.maxRequestSizeInBytes=s.maxRequestSizeInBytes||B,this.requestAbortTimeout=s.requestAbortTimeout,this.logger=s.logger},this._validateConstructorParams(e,t,n,r),this.accessKeyId=e,this.secretAccessKey=t,this.endpoint=n,this.bucketName=r,this.region=o,this.maxRequestSizeInBytes=i,this.requestAbortTimeout=a,this.logger=c}_validateConstructorParams(e,t,n,r){if(typeof e!="string"||e.trim().length===0)throw new TypeError(re);if(typeof t!="string"||t.trim().length===0)throw new TypeError(ne);if(typeof n!="string"||n.trim().length===0)throw new TypeError(oe);if(typeof r!="string"||r.trim().length===0)throw new TypeError(ie)}_checkMethodHeadnGet(e){if(e!=="GET"&&e!=="HEAD")throw this._log("error",`${h}method must be either GET or HEAD`),new Error("method must be either GET or HEAD")}_checkKey(e){if(typeof e!="string"||e.trim().length===0)throw this._log("error",k),new TypeError(k)}_checkDelimiter(e){if(typeof e!="string"||e.trim().length===0)throw this._log("error",v),new TypeError(v)}_checkPrefix(e){if(typeof e!="string")throw this._log("error",z),new TypeError(z)}_checkMaxKeys(e){if(typeof e!="number"||e<=0)throw this._log("error",Q),new TypeError(Q)}_checkOpts(e){if(typeof e!="object")throw this._log("error",`${h}opts must be an object`),new TypeError(`${h}opts must be an object`)}_log(e,t,n={}){if(this.logger&&typeof this.logger[e]=="function"){let r=a=>typeof a!="object"||a===null?a:Object.keys(a).reduce((c,s)=>(J.includes(s.toLowerCase())?c[s]="[REDACTED]":typeof a[s]=="object"&&a[s]!==null?c[s]=r(a[s]):c[s]=a[s],c),Array.isArray(a)?[]:{}),o=r(n),i={timestamp:new Date().toISOString(),level:e,message:t,...o,context:r({bucketName:this.bucketName,region:this.region,endpoint:this.endpoint,accessKeyId:this.accessKeyId?`${this.accessKeyId.substring(0,4)}...`:void 0})};this.logger[e](i)}}async getContentLength(e){this._checkKey(e);let t={[f]:_},n=y(e),{url:r,headers:o}=await this._sign("HEAD",n,{},t,""),a=(await this._sendRequest(r,"HEAD",o)).headers.get(R);return a?parseInt(a,10):0}async bucketExists(){let e={[f]:_},{url:t,headers:n}=await this._sign("HEAD","",{},e,""),r=await this._sendRequest(t,"HEAD",n,"",[200,404,403]);return this._log("error",`Response status: ${r.status,r.statusText}`),!!(r.ok&&r.status===200)}async createBucket(){let e=` + + ${this.region} + + `,t={[m]:K,[R]:Buffer.byteLength(e).toString(),[f]:await S(e)},n=encodeURI(""),{url:r,headers:o}=await this._sign("PUT",n,{},t,""),i=await this._sendRequest(r,"PUT",o,e,[200,404,403]);return!!(i.ok&&i.status===200)}async fileExists(e,t={}){this._checkKey(e);let{filteredOpts:n,conditionalHeaders:r}=this._filterIfHeaders(t),o={[f]:_,...r},i=y(e),{url:a,headers:c}=await this._sign("HEAD",i,n,o,"");try{let s=await this._sendRequest(a,"HEAD",c,"",[200,404,412,304]);return s.status===404?!1:s.status===412||s.status===304?null:s.ok&&s.status===200?!0:(this._handleErrorResponse(s),!1)}catch(s){let l=s instanceof Error?s.message:String(s);throw this._log("error",`${h}Failed to check if file exists: ${l}`),new Error(`${h}Failed to check if file exists: ${l}`)}}async _sign(e,t,n={},r,o){let i=new Date().toISOString().replace(/[:-]|\.\d{3}/g,""),a=typeof t=="string"&&t.length>0?new URL(t,this.endpoint):new URL(this.endpoint);a.pathname=`/${encodeURI(this.bucketName)}${a.pathname}`,r[f]=o?await S(o):_,r[ee]=i,r[te]=a.host;let c=this._buildCanonicalHeaders(r),s=Object.keys(r).map(E=>E.toLowerCase()).sort().join(";"),l=await this._buildCanonicalRequest(e,a,n,c,s,o),u=await this._buildStringToSign(i,l),d=await this._calculateSignature(i,u),g=this._buildAuthorizationHeader(i,s,d);return r[se]=g,{url:a.toString(),headers:r}}_buildCanonicalHeaders(e){return Object.entries(e).map(([t,n])=>`${t.toLowerCase()}:${String(n).trim()}`).sort().join(` +`)}async _buildCanonicalRequest(e,t,n,r,o,i){return[e,t.pathname,this._buildCanonicalQueryString(n),`${r} +`,o,i?await S(i):_].join(` +`)}async _buildStringToSign(e,t){let n=[e.slice(0,8),this.region,C,I].join("/");return[N,e,n,await S(t)].join(` +`)}async _calculateSignature(e,t){let n=await this._getSignatureKey(e.slice(0,8));return H(n,t,"hex")}_buildAuthorizationHeader(e,t,n){let r=[e.slice(0,8),this.region,C,I].join("/");return[`${N} Credential=${this.accessKeyId}/${r}`,`SignedHeaders=${t}`,`Signature=${n}`].join(", ")}_filterIfHeaders(e){let t={},n={},r=["if-match","if-none-match","if-modified-since","if-unmodified-since"];for(let[o,i]of Object.entries(e))r.includes(o)?n[o]=i:t[o]=i;return{filteredOpts:t,conditionalHeaders:n}}async list(e="/",t="",n=1e3,r="GET",o={}){this._checkDelimiter(e),this._checkPrefix(t),this._checkMaxKeys(n),this._checkMethodHeadnGet(r),this._checkOpts(o),this._log("info",`Listing objects in ${t}`);let i={"list-type":V,"max-keys":String(n),...o};t.length>0&&(i.prefix=t);let a={[m]:w,[f]:_},c=e==="/"?e:O(e),{url:s,headers:l}=await this._sign("GET",c,i,a,""),u=`${s}?${new URLSearchParams(i)}`,d=await this._sendRequest(u,"GET",l),g=await d.text();if(r==="HEAD"){let P=d.headers.get(R),M=d.headers.get(j),Y=d.headers.get(L);return{size:P?+P:void 0,mtime:M?new Date(M):void 0,ETag:Y||void 0}}let E=A(g),q=E.listBucketResult||E.error||E;return q.contents||q}async listMultiPartUploads(e="/",t="",n="GET",r={}){this._checkDelimiter(e),this._checkPrefix(t),this._checkMethodHeadnGet(n),this._checkOpts(r),this._log("info",`Listing multipart uploads in ${t}`);let o={uploads:"",...r},i={[m]:w,[f]:_},a=e==="/"?e:O(e),{url:c,headers:s}=await this._sign("GET",a,o,i,""),l=`${c}?${new URLSearchParams(o)}`,u=await this._sendRequest(l,"GET",s),d=await u.text();if(n==="HEAD")return{size:+(u.headers.get(R)??"0"),mtime:new Date(u.headers.get(j)??""),ETag:u.headers.get(L)??""};let g=A(d),E=g.listMultipartUploadsResult||g.error||g;return E.uploads||E}async get(e,t={}){this._checkKey(e),this._log("info",`Getting object ${e}`);let{filteredOpts:n,conditionalHeaders:r}=this._filterIfHeaders(t),o={[m]:w,[f]:_,...r},i=y(e),{url:a,headers:c}=await this._sign("GET",i,n,o,""),s=await this._sendRequest(a,"GET",c,"",[200,404,412,304]);if(s.status===404||s.status===412||s.status===304)return this._log("error",`Failed to get object. Status: ${s.status}`),null;if(!s.ok)throw this._log("error",`Failed to get object. Status: ${s.status}`),new Error(`Failed to get object. Status: ${s.status}`);return s}async getObjectWithETag(e,t={}){this._checkKey(e),this._log("info",`Getting object ${e}`);let{filteredOpts:n,conditionalHeaders:r}=this._filterIfHeaders(t),o={[m]:w,[f]:_,...r},i=y(e),{url:a,headers:c}=await this._sign("GET",i,n,o,"");try{let s=await this._sendRequest(a,"GET",c,"",[200,404,412,304]);if(s.status===404||s.status===412||s.status===304)return this._log("error",`Failed to get object. Status: ${s.status}`),{etag:null,data:null};if(!s.ok)throw this._log("error",`Failed to get object. Status: ${s.status}`),new Error(`Failed to get object. Status: ${s.status}`);let l=s.headers.get("etag");if(!l)throw new Error("ETag not found in response headers");let u=await s.text();return{etag:U(l),data:u}}catch(s){throw this._log("error",`Error getting object ${e} with ETag: ${s}`),s}}async getEtag(e,t={}){this._checkKey(e),this._log("info",`Getting etag object ${e}`);let{filteredOpts:n,conditionalHeaders:r}=this._filterIfHeaders(t),o={[m]:w,[f]:_,...r},i=y(e),{url:a,headers:c}=await this._sign("HEAD",i,n,o,""),s=await this._sendRequest(a,"HEAD",c,"",[200,412,304]);if(this._log("info",`Response status: ${s.status,s.statusText}`),s.status===412||s.status===304)return null;let l=s.headers.get("etag");if(!l)throw this._log("error","ETag not found in response headers"),new Error("ETag not found in response headers");return U(l)}async getResponse(e,t=!0,n=0,r=this.maxRequestSizeInBytes,o={}){this._checkKey(e);let{filteredOpts:i,conditionalHeaders:a}=this._filterIfHeaders({...o}),c={[m]:w,[f]:_,...t?{}:{range:`bytes=${n}-${r-1}`},...a},s=y(e),{url:l,headers:u}=await this._sign("GET",s,i,c,""),d=`${l}?${new URLSearchParams(i)}`;return this._sendRequest(d,"GET",u)}async put(e,t){if(this._checkKey(e),!(t instanceof Buffer||typeof t=="string"))throw this._log("error",D),new TypeError(D);this._log("info",`Uploading object ${e}`);let n=typeof t=="string"?Buffer.byteLength(t):t.length,r={[R]:n},o=y(e),{url:i,headers:a}=await this._sign("PUT",o,{},r,t);return this._sendRequest(i,"PUT",a,t,[200])}async getMultipartUploadId(e,t=Z){if(this._checkKey(e),typeof t!="string")throw this._log("error",`${h}fileType must be a string`),new TypeError(`${h}fileType must be a string`);this._log("info",`Initiating multipart upload for object ${e}`);let n={uploads:""},r={[m]:t,[f]:_},o=y(e),{url:i,headers:a}=await this._sign("POST",o,n,r,""),c=`${i}?${new URLSearchParams(n)}`,l=await(await this._sendRequest(c,"POST",a)).text(),u=A(l);if(typeof u=="object"&&u!==null&&"error"in u&&typeof u.error=="object"&&u.error!==null&&"message"in u.error){let d=String(u.error.message);throw this._log("error",`${h}Failed to abort multipart upload: ${d}`),new Error(`${h}Failed to abort multipart upload: ${d}`)}if(typeof u=="object"&&u!==null){if(!u.initiateMultipartUploadResult||!u.initiateMultipartUploadResult.uploadId)throw this._log("error",`${h}Failed to create multipart upload: no uploadId in response`),new Error(`${h}Failed to create multipart upload: Missing upload ID in response`);return u.initiateMultipartUploadResult.uploadId}else throw this._log("error",`${h}Failed to create multipart upload: unexpected response format`),new Error(`${h}Failed to create multipart upload: Unexpected response format`)}async uploadPart(e,t,n,r,o={}){this._validateUploadPartParams(e,t,n,r,o);let i={uploadId:n,partNumber:r,...o},a={[R]:t.length},c=y(e),{url:s,headers:l}=await this._sign("PUT",c,i,a,t),u=`${s}?${new URLSearchParams(i)}`,d=await this._sendRequest(u,"PUT",l,t),g=U(d.headers.get("etag")||"");return{partNumber:r,ETag:g}}_validateUploadPartParams(e,t,n,r,o){if(this._checkKey(e),!(t instanceof Buffer||typeof t=="string"))throw this._log("error",D),new TypeError(D);if(typeof n!="string"||n.trim().length===0)throw this._log("error",b),new TypeError(b);if(!Number.isInteger(r)||r<=0)throw this._log("error",`${h}partNumber must be a positive integer`),new TypeError(`${h}partNumber must be a positive integer`);this._checkOpts(o)}async completeMultipartUpload(e,t,n){if(this._checkKey(e),typeof t!="string"||t.trim().length===0)throw this._log("error",b),new TypeError(b);if(!Array.isArray(n)||n.length===0)throw this._log("error",F),new TypeError(F);if(!n.every(E=>typeof E.partNumber=="number"&&typeof E.ETag=="string"))throw this._log("error",G),new TypeError(G);this._log("info",`Complete multipart upload ${t} for object ${e}`);let r={uploadId:t},o=this._buildCompleteMultipartUploadXml(n),i={[m]:K,[R]:Buffer.byteLength(o).toString(),[f]:await S(o)},a=y(e),{url:c,headers:s}=await this._sign("POST",a,r,i,o),l=`${c}?${new URLSearchParams(r)}`,d=await(await this._sendRequest(l,"POST",s,o)).text(),g=A(d);if(typeof g=="object"&&g!==null&&"error"in g&&typeof g.error=="object"&&g.error!==null&&"message"in g.error){let E=String(g.error.message);throw this._log("error",`${h}Failed to abort multipart upload: ${E}`),new Error(`${h}Failed to abort multipart upload: ${E}`)}return g.completeMultipartUploadResult}async abortMultipartUpload(e,t){if(this._checkKey(e),typeof t!="string"||t.trim().length===0)throw this._log("error",b),new TypeError(b);this._log("info",`Aborting multipart upload ${t} for object ${e}`);let n={uploadId:t},r={[m]:K,[f]:_};try{let o=y(e),{url:i,headers:a}=await this._sign("DELETE",o,n,r,""),c=`${i}?${new URLSearchParams(n)}`,s=await this._sendRequest(c,"DELETE",a);if(s.ok){let l=await s.text(),u=A(l);if(typeof u=="object"&&u!==null&&"error"in u&&typeof u.error=="object"&&u.error!==null&&"message"in u.error){let d=String(u.error.message);throw this._log("error",`${h}Failed to abort multipart upload: ${d}`),new Error(`${h}Failed to abort multipart upload: ${d}`)}return{status:"Aborted",key:e,uploadId:t,response:u}}else throw this._log("error",`${h}Abort request failed with status ${s.status}`),new Error(`${h}Abort request failed with status ${s.status}`)}catch(o){let i=o instanceof Error?o.message:String(o);throw this._log("error",`${h}Failed to abort multipart upload for key ${e}: ${i}`),new Error(`${h}Failed to abort multipart upload for key ${e}: ${i}`)}}_buildCompleteMultipartUploadXml(e){return` + + ${e.map(t=>` + + ${t.partNumber} + ${t.ETag} + + `).join("")} + + `}async delete(e){this._checkKey(e),this._log("info",`Deleting object ${e}`);let t={[m]:w,[f]:_},n=y(e),{url:r,headers:o}=await this._sign("DELETE",n,{},t,""),i=await this._sendRequest(r,"DELETE",o);return i.status===204||i.status===200}async _sendRequest(e,t,n,r,o=[]){this._log("info",`Sending ${t} request to ${e}, headers: ${JSON.stringify(n)}`);let i=await fetch(e,{method:t,headers:n,body:["GET","HEAD"].includes(t)?void 0:r,signal:this.requestAbortTimeout!==void 0?AbortSignal.timeout(this.requestAbortTimeout):void 0,mode:"cors",credentials:"omit",cache:"no-store"});return this._log("info",`Response status: ${i.status,o}`),!i.ok&&!o.includes(i.status)&&await this._handleErrorResponse(i),i}async _handleErrorResponse(e){let t=await e.text(),n=e.headers.get("x-amz-error-code")||"Unknown",r=e.headers.get("x-amz-error-message")||e.statusText;throw this._log("error",`${h}Request failed with status ${e.status}: ${n} - ${r},err body: ${t}`),new Error(`${h}Request failed with status ${e.status}: ${n} - ${r}, err body: ${t}`)}_buildCanonicalQueryString(e){return Object.keys(e).length<1?"":Object.keys(e).sort().map(t=>`${encodeURIComponent(t)}=${encodeURIComponent(e[t])}`).join("&")}async _getSignatureKey(e){let t=await H(`AWS4${this.secretAccessKey}`,e),n=await H(t,this.region),r=await H(n,C);return H(r,I)}},S=async p=>{let e=$("sha256");return e.update(p),e.digest("hex")},H=async(p,e,t)=>{let n=T("sha256",p);return n.update(e),n.digest(t)},U=p=>{let e={'"':"",""":"",""":"",""":"",""":""};return p.replace(/^("|"|")|("|"|")$/g,t=>e[t])},A=p=>{let e=o=>o.replace(/"/g,'"').replace(/'/g,"'").replace(/</g,"<").replace(/>/g,">").replace(/&/g,"&"),t={},n=/<(\w)([-\w]+)(?:\/|[^>]*>((?:(?!<\1)[\s\S])*)<\/\1\2)>/gm,r;for(;r=n.exec(p);){let[,o,i,a]=r,c=o.toLowerCase()+i,s=a!=null?A(a):!0;typeof s=="string"?t[c]=U(e(s)):Array.isArray(t[c])?t[c].push(s):t[c]=t[c]!=null?[t[c],s]:ae[c]?[s]:s}return Object.keys(t).length?t:e(p)};var le=x;export{x as S3,le as default,U as sanitizeETag}; +//# sourceMappingURL=index.min.js.map diff --git a/src/index.ts b/src/index.ts index 46906af..b1ed7bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -109,9 +109,11 @@ type HttpMethod = 'POST' | 'GET' | 'HEAD' | 'PUT' | 'DELETE'; // null - ETag mismatch (412) type ExistResponseCode = false | true | null; -let _createHmac = crypto.createHmac || (await import('node:crypto')).createHmac; -let _createHash = crypto.createHash || (await import('node:crypto')).createHash; +// the old way to work with crypto - without browser support +// let _createHmac = crypto.createHmac || (await import('node:crypto')).createHmac; +// let _createHash = crypto.createHash || (await import('node:crypto')).createHash; +import { _createHmac, _createHash } from 'crypto-wrapper'; if (typeof _createHmac === 'undefined' && typeof _createHash === 'undefined') { console.error( 'ultralight-S3 Module: Crypto functions are not available, please report the issue with necessary description: https://github.com/sentienhq/ultralight-s3/issues', @@ -1087,11 +1089,19 @@ class S3 { toleratedStatusCodes: number[] = [], ): Promise { this._log('info', `Sending ${method} request to ${url}, headers: ${JSON.stringify(headers)}`); + // Remove forbidden headers + // const safeHeaders = { ...headers }; + // delete safeHeaders[HEADER_HOST]; // Browser sets this automatically + // delete safeHeaders[HEADER_CONTENT_LENGTH]; // Browser sets this based on the body + const res = await fetch(url, { method, headers, body: ['GET', 'HEAD'].includes(method) ? undefined : body, signal: this.requestAbortTimeout !== undefined ? AbortSignal.timeout(this.requestAbortTimeout) : undefined, + mode: 'cors', // Ensure CORS mode is enabled + credentials: 'omit', // Ensure credentials are included + cache: 'no-store', }); this._log('info', `Response status: ${(res.status, toleratedStatusCodes)}`); if (!res.ok && !toleratedStatusCodes.includes(res.status)) { @@ -1120,7 +1130,7 @@ class S3 { return Object.keys(queryParams) .sort() - .map(key => `${encodeURIComponent(key)}=${encodeURIComponent((queryParams as Record)[key])}`) + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent((queryParams as any)[key])}`) .join('&'); } async _getSignatureKey(dateStamp: string): Promise { diff --git a/src/utils/crypto-wrapper.ts b/src/utils/crypto-wrapper.ts new file mode 100644 index 0000000..fa482ce --- /dev/null +++ b/src/utils/crypto-wrapper.ts @@ -0,0 +1,123 @@ +type HashAlgorithm = 'sha256'; +type Encoding = 'hex' | 'base64' | 'latin1'; + +interface Hmac { + update(data: string | Buffer): void; + digest(encoding?: Encoding): Promise; +} + +interface Hash { + update(data: string | Buffer): void; + digest(encoding?: Encoding): Promise; +} + +type HashFunction = (algorithm: HashAlgorithm) => Hash; +type HmacFunction = (algorithm: HashAlgorithm, key: string | Buffer) => Hmac; + +let _createHmac: any = crypto.createHmac; +let _createHash: any = crypto.createHash; + +if (typeof _createHmac === 'undefined' || typeof _createHash === 'undefined') { + try { + const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null; + if (isNode) { + // Import `crypto` from Node if available (useful for Node.js environments). + const nodeCrypto = await import('node:crypto'); + _createHmac = nodeCrypto.createHmac; + _createHash = nodeCrypto.createHash; + } else { + function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array { + const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; + } + + function encodeDigest(buffer: ArrayBuffer, encoding: String) { + const hashArray = Array.from(new Uint8Array(buffer)); + + if (encoding === 'hex') { + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + } else if (encoding === 'base64') { + const binary = String.fromCharCode(...hashArray); + return btoa(binary); + } else if (encoding === 'latin1') { + return String.fromCharCode(...hashArray); + } else { + throw new Error(`Unsupported encoding: ${encoding}`); + } + } + + // Browser-compatible `createHash` using `crypto.subtle` for SHA-256 + _createHash = (algorithm: string): any => { + if (algorithm !== 'sha256') throw new Error('Only SHA-256 is supported in the browser.'); + + const chunks: Uint8Array[] = []; + + return { + update(data: string | Buffer) { + const encoder = new TextEncoder(); + const encoded = typeof data === 'string' ? encoder.encode(data) : new Uint8Array(data); + chunks.push(encoded); + // Allow method chaining + return this; + }, + async digest(encoding = 'hex') { + const concatenated = concatUint8Arrays(chunks); + const hashBuffer = await crypto.subtle.digest('SHA-256', concatenated); + return encodeDigest(hashBuffer, encoding); + }, + }; + }; + + // Browser-compatible `createHmac` using a polyfill approach (for HMAC). + _createHmac = (algorithm: string, key: string | Buffer) => { + if (algorithm !== 'sha256') throw new Error('Only SHA-256 HMAC is supported in the browser.'); + + const chunks: Uint8Array[] = []; + const encoder = new TextEncoder(); + const keyData = typeof key === 'string' ? encoder.encode(key) : new Uint8Array(key); + + let cryptoKeyPromise: Promise | null = null; + + function ensureCryptoKey() { + if (!cryptoKeyPromise) { + cryptoKeyPromise = crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: { name: 'SHA-256' } }, + false, + ['sign'], + ); + } + return cryptoKeyPromise; + } + + return { + update(data: string | Buffer) { + const encoded = typeof data === 'string' ? encoder.encode(data) : new Uint8Array(data); + chunks.push(encoded); + // Allow method chaining + return this; + }, + async digest(encoding = 'hex') { + const cryptoKey = await ensureCryptoKey(); + const concatenated = concatUint8Arrays(chunks); + const signature = await crypto.subtle.sign('HMAC', cryptoKey, concatenated); + return encodeDigest(signature, encoding); + }, + }; + }; + } + } catch (e) { + console.warn( + 'ultralight-s3 Module: Crypto functions are not available. Using SubtleCrypto for browser compatibility.', + ); + } +} + +export { _createHmac, _createHash }; diff --git a/tsconfig.json b/tsconfig.json index f46391f..ce7125a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,11 @@ "experimentalDecorators": true, "esModuleInterop": true, "sourceMap": true, + "baseUrl": ".", + "paths": { + "crypto-wrapper": ["src/utils/crypto-wrapper.ts"] + } }, - "include": ["src"], + "include": ["src/*", "src/utils/crypto-wrapper.ts"], "exclude": ["node_modules", "dev", "tests", "lib"], }