@file-goblin/upload-goblin
Version:
Alchemy-powered file uploader for Cloudflare Workers with R2 and S3 support
2 lines (1 loc) • 9.66 kB
JavaScript
import b from"crypto";function m(t,e,o){try{let[s,r]=t.split(".");if(!s||!r)return{valid:!1,error:"Invalid token format"};let a;try{let c=Buffer.from(s,"base64url").toString();a=JSON.parse(c)}catch{return{valid:!1,error:"Invalid token format"}}let l=b.createHmac("sha256",e).update(s).digest("base64url");if(r!==l)return{valid:!1,error:"Invalid signature"};if(Date.now()>a.expiresAt)return{valid:!1,error:"Token expired"};if(a.allowedFileType||a.allowedMimeType){let c=o.headers.get("Content-Type"),n=a.allowedFileType||a.allowedMimeType;if(!v(c,n))return{valid:!1,error:`Content type not allowed. Expected ${n}, got ${c}`}}if(a.maxFileSize||a.maxSizeBytes){let c=parseInt(o.headers.get("Content-Length")||"0"),n=a.maxFileSize||a.maxSizeBytes;if(c>n)return{valid:!1,error:`File too large. Maximum size: ${n} bytes, got: ${c} bytes`}}return{valid:!0,payload:a}}catch{return{valid:!1,error:"Invalid token format"}}}function v(t,e){if(!t)return!1;if(e==="*"||e==="*/*")return!0;if(e.endsWith("/*")){let o=e.slice(0,-2);return t.startsWith(o+"/")}return t===e}function R(t){return{handleUpload:async e=>{try{let o=t.contextFn?await t.contextFn(e):{},s=new URL(e.url),r=s.pathname.split("/"),a=r[r.length-1];if(!a)return new Response(JSON.stringify({error:"File ID is required"}),{status:400,headers:{"Content-Type":"application/json"}});let l=await t.validateUploadRequest({req:e,fileId:a,context:o});if(!l.ok)return new Response(JSON.stringify({error:"Request validation failed",details:l.error.message}),{status:l.error.code,headers:{"Content-Type":"application/json"}});if(t.preUpload){let g=await t.preUpload({req:e,context:o});if(!g.ok)return new Response(JSON.stringify({error:"Pre-upload validation failed",details:g.error.message}),{status:g.error.code,headers:{"Content-Type":"application/json"}})}if(!e.body)return new Response(JSON.stringify({error:"Request body is required"}),{status:400,headers:{"Content-Type":"application/json"}});let c=e.body,n={},i=e.headers.get("Content-Type");i&&(n.contentType=i);let d=e.headers.get("Content-Length");d&&(n.contentLength=parseInt(d,10));let y=s.searchParams.get("fileName");y&&(n.fileName=y);for(let[g,S]of e.headers.entries())if(g.toLowerCase().startsWith("x-metadata-")){let U=g.substring(11).split("-").map(h=>h.charAt(0).toUpperCase()+h.slice(1).toLowerCase()).join("-");n[U]=S}let f=await t.storage.putBytes(a,c,n,o);if(!f.ok)return new Response(JSON.stringify({error:"Failed to store file",details:f.error.message}),{status:f.error.code,headers:{"Content-Type":"application/json"}});if(t.postUpload){let g=await t.postUpload({req:e,fileId:a,metadata:n,context:o});g.ok||console.error("Post-upload hook failed:",g.error)}return new Response(JSON.stringify({success:!0,fileId:a,message:"File uploaded successfully"}),{status:200,headers:{"Content-Type":"application/json"}})}catch(o){return console.error("Upload error:",o),new Response(JSON.stringify({error:"Internal server error",details:o.message||"Unknown error occurred"}),{status:500,headers:{"Content-Type":"application/json"}})}},handleDownload:async e=>{try{let o=t.contextFn?await t.contextFn(e):{},s=new URL(e.url),r=s.pathname.split("/"),a=r[r.length-1];if(!a)return new Response(JSON.stringify({error:"File ID is required"}),{status:400,headers:{"Content-Type":"application/json"}});if(t.downloadValidation!==void 0)if(typeof t.downloadValidation=="string"){let d=e.headers.get("Authorization")?.replace("Bearer ","")||s.searchParams.get("signature");if(!d)return new Response(JSON.stringify({error:"Signature required for download"}),{status:401,headers:{"Content-Type":"application/json"}});let y=m(d,t.downloadValidation,e);if(!y.valid)return new Response(JSON.stringify({error:"Download signature validation failed",details:y.error}),{status:401,headers:{"Content-Type":"application/json"}})}else{let d=await t.downloadValidation({req:e,fileId:a,context:o});if(!d.valid)return new Response(JSON.stringify({error:"Download validation failed",details:d.error||"Access denied"}),{status:403,headers:{"Content-Type":"application/json"}})}let l=await t.storage.getBytes(a,o);if(!l.ok)return new Response(JSON.stringify({error:"File not found",details:l.error.message}),{status:l.error.code,headers:{"Content-Type":"application/json"}});let{data:c,metadata:n}=l.value,i=new Headers;if(n.contentType?i.set("Content-Type",n.contentType):i.set("Content-Type","application/octet-stream"),n.fileName){let d=s.searchParams.get("disposition")==="inline"?"inline":"attachment";i.set("Content-Disposition",`${d}; filename="${n.fileName}"`)}return Object.entries(n).forEach(([d,y])=>{d!=="contentType"&&d!=="fileName"&&i.set(`X-Metadata-${d}`,String(y))}),i.set("Access-Control-Allow-Origin","*"),i.set("Access-Control-Expose-Headers","*"),new Response(c,{status:200,headers:i})}catch(o){return console.error("Download error:",o),new Response(JSON.stringify({error:"Internal server error",details:o.message||"Unknown error occurred"}),{status:500,headers:{"Content-Type":"application/json"}})}}}}async function x(t){try{let e=await t;return u(e)}catch(e){if(e&&typeof e=="object"&&"code"in e&&"message"in e)return p(e);let o=e instanceof globalThis.Error?e.message:String(e);return p({code:500,message:o||"An unexpected error occurred"})}}function u(t){return{tag:"ok",value:t===void 0?void 0:t,ok:!0}}var p=t=>({tag:"err",error:t,ok:!1});var C=t=>({putBytes:async(e,o,s)=>{let r=s||{};return await x(t.put(e,o,{customMetadata:r})).then(()=>u(void 0))},getBytes:async e=>{let o=await x(t.get(e));if(!o.ok)return o;let s=o.value;if(s===null)return p({code:404,message:`No object found with key: ${e}`});let r=s.customMetadata||{};return u({data:s.body,metadata:r})},deleteBytes:async e=>await x(t.delete(e)).then(()=>u(void 0))});import{AwsClient as k}from"aws4fetch";function E(t){return/[^\x00-\x7F]/.test(t)?`=?UTF-8?B?${btoa(unescape(encodeURIComponent(t)))}?=`:t}function T(t){let e=new k({accessKeyId:t.credentials.accessKeyId,secretAccessKey:t.credentials.secretAccessKey,region:t.region,service:"s3"}),o=t.endpoint;return{async putBytes(s,r,a){if(!(r instanceof ReadableStream||r instanceof ArrayBuffer||r instanceof Blob||typeof r=="string"))return p({code:400,message:"Unsupported content type"});let l=r,c=a?.contentType||"application/octet-stream";try{let n={};Object.entries(a||{}).forEach(([d,y])=>{let f=String(y);f=E(f),n[`x-amz-meta-${d.toLowerCase()}`]=f});let i=await e.fetch(`${o}/${encodeURIComponent(s)}`,{method:"PUT",body:l,headers:{"Content-Type":c,"x-amz-content-sha256":"UNSIGNED-PAYLOAD",...n}});if(!i.ok){let d=await i.text();return p({code:i.status,message:d||"Failed to upload object to S3"})}return u()}catch(n){return p({code:500,message:n.message||"Unknown error occurred during upload"})}},async getBytes(s){try{let r=await e.fetch(`${o}/${encodeURIComponent(s)}`,{headers:{"x-amz-content-sha256":"UNSIGNED-PAYLOAD"}});if(!r.ok){if(r.status===404)return p({code:404,message:"Object not found"});let l=await r.text();return p({code:r.status,message:l||"Failed to retrieve object from S3"})}let a={};return r.headers.forEach((l,c)=>{if(c.startsWith("x-amz-meta-")){let n=c.substring(11),i=l;if(l.startsWith("=?UTF-8?B?")&&l.endsWith("?="))try{let d=l.slice(10,-2);i=decodeURIComponent(escape(atob(d)))}catch{i=l}a[n]=i}}),r.headers.get("content-type")&&(a.contentType=r.headers.get("content-type")),u({data:r.body,metadata:a})}catch(r){return p({code:500,message:r.message||"Unknown error occurred during retrieval"})}},async deleteBytes(s){try{let r=await e.fetch(`${o}/${encodeURIComponent(s)}`,{method:"DELETE",headers:{"x-amz-content-sha256":"UNSIGNED-PAYLOAD"}});if(!r.ok){let a=await r.text();return p({code:r.status,message:a||"Failed to delete object from S3"})}return u()}catch(r){return p({code:500,message:r.message||"Unknown error occurred during deletion"})}}}}import B from"crypto";function A(t,e){let o=JSON.stringify(t),s=Buffer.from(o).toString("base64url"),r=B.createHmac("sha256",e).update(s).digest("base64url");return`${s}.${r}`}import{isSecret as w}from"alchemy";import{Worker as F}from"alchemy/cloudflare";async function I(t,e){let o,s;e.storage.type==="r2"?o=C(e.storage.bucket):e.storage.type==="s3"?o=T({credentials:{accessKeyId:e.storage.credentials.accessKeyId.unencrypted,secretAccessKey:e.storage.credentials.secretAccessKey.unencrypted},endpoint:e.storage.endpoint,region:e.storage.region}):o=e.storage.storage;let r=w(e.uploadValidation)?async({req:n})=>{let i=n.headers.get("Authorization")?.replace("Bearer ","")||new URL(n.url).searchParams.get("signature");if(!i)return p({code:401,message:"Signature required"});let d=m(i,e.uploadValidation.unencrypted,n);return d.valid?u():p({code:d.error?.includes("Content type")?415:d.error?.includes("File too large")?413:401,message:d.error||"Invalid signature"})}:async n=>{let i=await e.uploadValidation(n);return i.valid?u():p({code:401,message:i.error||"Validation failed"})},a={storage:o,validateUploadRequest:r,downloadValidation:w(e.downloadValidation)?e.downloadValidation.unencrypted:e.downloadValidation,preUpload:e.preUpload,postUpload:e.postUpload,contextFn:e.contextFn},l=R(a);return{worker:F(t,import.meta,{name:e.name,compatibilityDate:"2025-03-10",...e.workerConfig,url:e.workerConfig?.url!==!1,async fetch(n){let i=new URL(n.url);return n.method==="PUT"&&i.pathname.startsWith("/uploads/")?l.handleUpload(n):n.method==="GET"&&i.pathname.startsWith("/uploads/")?l.handleDownload(n):new Response("Not Found",{status:404})}}),bucket:s,storage:o,uploader:l}}export{I as GoblinUploader,C as R2BlobStorage,T as S3BlobStorage,R as createUploader,p as err,A as generateUploadSignature,u as ok,x as safeAsync,m as validateSignature};