@stacksjs/storage 0.72.38 → 0.72.40
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/adapters/memory.js +1 -1
- package/dist/adapters/s3.js +1 -1
- package/dist/adapters/scoped.js +1 -1
- package/dist/facade.js +1 -1
- package/dist/put-file.js +1 -1
- package/package.json +6 -6
package/dist/adapters/memory.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{Buffer}from"node:buffer";import{basename}from"node:path";import{createDirectoryListing,normalizeExpiryToDate}from"../types";export class InMemoryStorageAdapter{files;directories;constructor(){this.files=new Map;this.directories=new Set;this.directories.add("")}normalizePath(path){return path.replace(/^\/+/,"").replace(/\/+$/,"")}getDirectoryPath(path){const parts=path.split("/").filter(Boolean);parts.pop();return parts.join("/")}async contentsToUint8Array(contents){if(typeof contents==="string")return new TextEncoder().encode(contents);else if(contents instanceof Buffer)return new Uint8Array(contents);else if(contents instanceof Uint8Array)return contents;else{if(typeof contents.getReader!=="function")throw TypeError("[storage/memory] contents must be a web-standard ReadableStream (with .getReader()), not a Node stream.Readable. Convert via Readable.toWeb(nodeStream) before passing.");const reader=contents.getReader(),chunks=[];while(!0){const{done,value}=await reader.read();if(done)break;if(value)chunks.push(value)}const totalLength=chunks.reduce((sum,chunk)=>sum+chunk.length,0),result=new Uint8Array(totalLength);let offset=0;for(const chunk of chunks){result.set(chunk,offset);offset+=chunk.length}return result}}async write(path,contents){const normalized=this.normalizePath(path),dirPath=this.getDirectoryPath(normalized);if(dirPath)await this.createDirectory(dirPath);const data=await this.contentsToUint8Array(contents),lastModified=Date.now(),mimeType=this.detectMimeType(normalized);this.files.set(normalized,{contents:data,visibility:"private",mimeType,lastModified});return{path:normalized,size:data.length,contentType:mimeType,lastModified}}async read(path){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);return file.contents}async getStream(path,_options){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);const bytes=file.contents;return new ReadableStream({start(controller){controller.enqueue(bytes);controller.close()}})}async putStream(path,stream,options){const normalized=this.normalizePath(path),dirPath=this.getDirectoryPath(normalized);if(dirPath)await this.createDirectory(dirPath);const chunks=[],reader=stream.getReader(),abort=options?.signal;try{while(!0){if(abort?.aborted)throw Error("aborted");const{done,value}=await reader.read();if(done)break;if(value)chunks.push(value)}}finally{try{reader.releaseLock()}catch{}}const totalLength=chunks.reduce((sum,c)=>sum+c.length,0),data=new Uint8Array(totalLength);let offset=0;for(const c of chunks){data.set(c,offset);offset+=c.length}const lastModified=Date.now(),mimeType=options?.contentType??this.detectMimeType(normalized);this.files.set(normalized,{contents:data,visibility:"private",mimeType,lastModified});return{path:normalized,size:data.length,contentType:mimeType,lastModified}}async readToString(path){const data=await this.read(path);return new TextDecoder().decode(data)}async readToBuffer(path){const data=await this.read(path);return Buffer.from(data)}async readToUint8Array(path){return await this.read(path)}async deleteFile(path){const normalized=this.normalizePath(path);if(!this.files.has(normalized))throw Error(`File not found: ${path}`);this.files.delete(normalized)}async deleteDirectory(path){const normalized=this.normalizePath(path),prefix=normalized?`${normalized}/`:"";for(const filePath of this.files.keys())if(filePath===normalized||filePath.startsWith(prefix))this.files.delete(filePath);for(const dir of this.directories)if(dir===normalized||dir.startsWith(prefix))this.directories.delete(dir)}async createDirectory(path){const normalized=this.normalizePath(path);if(!normalized)return;const parts=normalized.split("/").filter(Boolean);let current="";for(const part of parts){current=current?`${current}/${part}`:part;this.directories.add(current)}}async moveFile(from,to){const normalizedFrom=this.normalizePath(from),normalizedTo=this.normalizePath(to),file=this.files.get(normalizedFrom);if(!file)throw Error(`File not found: ${from}`);const toDir=this.getDirectoryPath(normalizedTo);if(toDir)await this.createDirectory(toDir);this.files.set(normalizedTo,{...file,lastModified:Date.now()});this.files.delete(normalizedFrom)}async copyFile(from,to){const normalizedFrom=this.normalizePath(from),normalizedTo=this.normalizePath(to),file=this.files.get(normalizedFrom);if(!file)throw Error(`File not found: ${from}`);const toDir=this.getDirectoryPath(normalizedTo);if(toDir)await this.createDirectory(toDir);const contentsCopy=new Uint8Array(file.contents);this.files.set(normalizedTo,{...file,contents:contentsCopy,lastModified:Date.now()})}async stat(path){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(file)return{path:normalized,type:"file",visibility:file.visibility,size:file.contents.length,lastModified:file.lastModified,mimeType:file.mimeType};if(this.directories.has(normalized)||normalized==="")return{path:normalized,type:"directory",visibility:"private",size:0,lastModified:Date.now()};throw Error(`Path not found: ${path}`)}list(path,options={}){return this.createAsyncIterator(path,options.deep||!1)}async*createAsyncIterator(path,deep){const normalized=this.normalizePath(path),prefix=normalized?`${normalized}/`:"",entries=[],seen=new Set;for(const[filePath]of this.files)if(filePath.startsWith(prefix)||prefix===""){const relativePath=prefix?filePath.slice(prefix.length):filePath;if(!deep){if(relativePath.split("/").filter(Boolean).length===1)entries.push({path:filePath,type:"file"})}else entries.push({path:filePath,type:"file"})}for(const dir of this.directories)if((dir.startsWith(prefix)||prefix==="")&&dir!==normalized){const relativePath=prefix?dir.slice(prefix.length):dir;if(!deep){if(relativePath.split("/").filter(Boolean).length===1&&!seen.has(dir)){entries.push({path:dir,type:"directory"});seen.add(dir)}}else if(!seen.has(dir)){entries.push({path:dir,type:"directory"});seen.add(dir)}}yield*createDirectoryListing(entries)}async changeVisibility(path,visibility){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);file.visibility=visibility}async visibility(path){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);return file.visibility}async fileExists(path){const normalized=this.normalizePath(path);return this.files.has(normalized)}async directoryExists(path){const normalized=this.normalizePath(path);return this.directories.has(normalized)||normalized===""}async publicUrl(path,options={}){return`${(options.domain||process.env.APP_URL||"http://localhost").replace(/\/$/,"")}/${this.normalizePath(path)}`}async temporaryUrl(path,options){const expiry=normalizeExpiryToDate(options.expiresIn);return`http://localhost/temp/${Buffer.from(`${path}:${expiry.getTime()}`).toString("base64url")}`}async signedUrl(_path,_options){throw Error("[storage/memory] signedUrl is not supported on the in-memory adapter
|
|
1
|
+
import{Buffer}from"node:buffer";import{basename}from"node:path";import{createDirectoryListing,normalizeExpiryToDate}from"../types";export class InMemoryStorageAdapter{files;directories;constructor(){this.files=new Map;this.directories=new Set;this.directories.add("")}normalizePath(path){return path.replace(/^\/+/,"").replace(/\/+$/,"")}getDirectoryPath(path){const parts=path.split("/").filter(Boolean);parts.pop();return parts.join("/")}async contentsToUint8Array(contents){if(typeof contents==="string")return new TextEncoder().encode(contents);else if(contents instanceof Buffer)return new Uint8Array(contents);else if(contents instanceof Uint8Array)return contents;else{if(typeof contents.getReader!=="function")throw TypeError("[storage/memory] contents must be a web-standard ReadableStream (with .getReader()), not a Node stream.Readable. Convert via Readable.toWeb(nodeStream) before passing.");const reader=contents.getReader(),chunks=[];while(!0){const{done,value}=await reader.read();if(done)break;if(value)chunks.push(value)}const totalLength=chunks.reduce((sum,chunk)=>sum+chunk.length,0),result=new Uint8Array(totalLength);let offset=0;for(const chunk of chunks){result.set(chunk,offset);offset+=chunk.length}return result}}async write(path,contents){const normalized=this.normalizePath(path),dirPath=this.getDirectoryPath(normalized);if(dirPath)await this.createDirectory(dirPath);const data=await this.contentsToUint8Array(contents),lastModified=Date.now(),mimeType=this.detectMimeType(normalized);this.files.set(normalized,{contents:data,visibility:"private",mimeType,lastModified});return{path:normalized,size:data.length,contentType:mimeType,lastModified}}async read(path){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);return file.contents}async getStream(path,_options){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);const bytes=file.contents;return new ReadableStream({start(controller){controller.enqueue(bytes);controller.close()}})}async putStream(path,stream,options){const normalized=this.normalizePath(path),dirPath=this.getDirectoryPath(normalized);if(dirPath)await this.createDirectory(dirPath);const chunks=[],reader=stream.getReader(),abort=options?.signal;try{while(!0){if(abort?.aborted)throw Error("aborted");const{done,value}=await reader.read();if(done)break;if(value)chunks.push(value)}}finally{try{reader.releaseLock()}catch{}}const totalLength=chunks.reduce((sum,c)=>sum+c.length,0),data=new Uint8Array(totalLength);let offset=0;for(const c of chunks){data.set(c,offset);offset+=c.length}const lastModified=Date.now(),mimeType=options?.contentType??this.detectMimeType(normalized);this.files.set(normalized,{contents:data,visibility:"private",mimeType,lastModified});return{path:normalized,size:data.length,contentType:mimeType,lastModified}}async readToString(path){const data=await this.read(path);return new TextDecoder().decode(data)}async readToBuffer(path){const data=await this.read(path);return Buffer.from(data)}async readToUint8Array(path){return await this.read(path)}async deleteFile(path){const normalized=this.normalizePath(path);if(!this.files.has(normalized))throw Error(`File not found: ${path}`);this.files.delete(normalized)}async deleteDirectory(path){const normalized=this.normalizePath(path),prefix=normalized?`${normalized}/`:"";for(const filePath of this.files.keys())if(filePath===normalized||filePath.startsWith(prefix))this.files.delete(filePath);for(const dir of this.directories)if(dir===normalized||dir.startsWith(prefix))this.directories.delete(dir)}async createDirectory(path){const normalized=this.normalizePath(path);if(!normalized)return;const parts=normalized.split("/").filter(Boolean);let current="";for(const part of parts){current=current?`${current}/${part}`:part;this.directories.add(current)}}async moveFile(from,to){const normalizedFrom=this.normalizePath(from),normalizedTo=this.normalizePath(to),file=this.files.get(normalizedFrom);if(!file)throw Error(`File not found: ${from}`);const toDir=this.getDirectoryPath(normalizedTo);if(toDir)await this.createDirectory(toDir);this.files.set(normalizedTo,{...file,lastModified:Date.now()});this.files.delete(normalizedFrom)}async copyFile(from,to){const normalizedFrom=this.normalizePath(from),normalizedTo=this.normalizePath(to),file=this.files.get(normalizedFrom);if(!file)throw Error(`File not found: ${from}`);const toDir=this.getDirectoryPath(normalizedTo);if(toDir)await this.createDirectory(toDir);const contentsCopy=new Uint8Array(file.contents);this.files.set(normalizedTo,{...file,contents:contentsCopy,lastModified:Date.now()})}async stat(path){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(file)return{path:normalized,type:"file",visibility:file.visibility,size:file.contents.length,lastModified:file.lastModified,mimeType:file.mimeType};if(this.directories.has(normalized)||normalized==="")return{path:normalized,type:"directory",visibility:"private",size:0,lastModified:Date.now()};throw Error(`Path not found: ${path}`)}list(path,options={}){return this.createAsyncIterator(path,options.deep||!1)}async*createAsyncIterator(path,deep){const normalized=this.normalizePath(path),prefix=normalized?`${normalized}/`:"",entries=[],seen=new Set;for(const[filePath]of this.files)if(filePath.startsWith(prefix)||prefix===""){const relativePath=prefix?filePath.slice(prefix.length):filePath;if(!deep){if(relativePath.split("/").filter(Boolean).length===1)entries.push({path:filePath,type:"file"})}else entries.push({path:filePath,type:"file"})}for(const dir of this.directories)if((dir.startsWith(prefix)||prefix==="")&&dir!==normalized){const relativePath=prefix?dir.slice(prefix.length):dir;if(!deep){if(relativePath.split("/").filter(Boolean).length===1&&!seen.has(dir)){entries.push({path:dir,type:"directory"});seen.add(dir)}}else if(!seen.has(dir)){entries.push({path:dir,type:"directory"});seen.add(dir)}}yield*createDirectoryListing(entries)}async changeVisibility(path,visibility){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);file.visibility=visibility}async visibility(path){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);return file.visibility}async fileExists(path){const normalized=this.normalizePath(path);return this.files.has(normalized)}async directoryExists(path){const normalized=this.normalizePath(path);return this.directories.has(normalized)||normalized===""}async publicUrl(path,options={}){return`${(options.domain||process.env.APP_URL||"http://localhost").replace(/\/$/,"")}/${this.normalizePath(path)}`}async temporaryUrl(path,options){const expiry=normalizeExpiryToDate(options.expiresIn);return`http://localhost/temp/${Buffer.from(`${path}:${expiry.getTime()}`).toString("base64url")}`}async signedUrl(_path,_options){throw Error("[storage/memory] signedUrl is not supported on the in-memory adapter - switch to local or s3 disk for signed URL generation.")}async checksum(path,options={}){const algorithm=options.algorithm||"sha256",data=await this.readToUint8Array(path),hasher=new Bun.CryptoHasher(algorithm);hasher.update(data);return hasher.digest("hex")}async mimeType(path,_options={}){const normalized=this.normalizePath(path),file=this.files.get(normalized);if(!file)throw Error(`File not found: ${path}`);return file.mimeType}detectMimeType(path){const ext=basename(path).split(".").pop()?.toLowerCase();return{txt:"text/plain",html:"text/html",css:"text/css",js:"application/javascript",json:"application/json",xml:"application/xml",pdf:"application/pdf",zip:"application/zip",jpg:"image/jpeg",jpeg:"image/jpeg",png:"image/png",gif:"image/gif",svg:"image/svg+xml",mp4:"video/mp4",mp3:"audio/mpeg",wav:"audio/wav"}[ext||""]||"application/octet-stream"}async lastModified(path){return(await this.stat(path)).lastModified}async fileSize(path){return(await this.stat(path)).size}clear(){this.files.clear();this.directories.clear();this.directories.add("")}}export function createMemoryStorage(){return new InMemoryStorageAdapter}
|
package/dist/adapters/s3.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{Buffer}from"node:buffer";import{basename}from"node:path";import{normalizeExpiryToMilliseconds}from"../types";import{sanitizePresignedDir,sanitizePresignedFilename}from"../path-sanitize";import{signS3PresignedPost}from"../s3-presigned-post";import process from"node:process";const S3_MIN_PART_SIZE=5242880,S3_MAX_PART_SIZE=5368709120;function clampPartSize(requested){if(!Number.isFinite(requested))return S3_MIN_PART_SIZE;return Math.max(S3_MIN_PART_SIZE,Math.min(Math.floor(requested),S3_MAX_PART_SIZE))}class ChunkBuffer{chunks=[];total=0;constructor(_partSize){}get length(){return this.total}push(c){this.chunks.push(c);this.total+=c.length}take(n){const out=new Uint8Array(n);let written=0;while(written<n&&this.chunks.length>0){const head=this.chunks[0],need=n-written;if(head.length<=need){out.set(head,written);written+=head.length;this.chunks.shift()}else{out.set(head.subarray(0,need),written);this.chunks[0]=head.subarray(need);written+=need}}this.total-=n;return out}flush(){const out=new Uint8Array(this.total);let off=0;for(const c of this.chunks){out.set(c,off);off+=c.length}this.chunks=[];this.total=0;return out}}async function isSettled(p){const sentinel=Symbol("pending");return await Promise.race([p.then(()=>"settled",()=>"settled"),Promise.resolve(sentinel)])!==sentinel}export function resolveS3ClientOptions(config){const options={};if(config.endpoint)options.endpoint=config.endpoint.replace(/^https?:\/\//i,"").replace(/\/+$/,"");if(config.usePathStyleEndpoint)options.forcePathStyle=!0;if(config.credentials)options.credentials=config.credentials;return Object.keys(options).length>0?options:void 0}export class S3StorageAdapter{_client;_clientPromise=null;bucket;prefix;region;endpoint;usePathStyleEndpoint;credentials;constructor(client,config){this._client=client;this.bucket=config.bucket||"";this.prefix=config.prefix||"";this.region=config.region||"us-east-1";this.credentials=config.credentials;this.endpoint=config.endpoint;this.usePathStyleEndpoint=config.usePathStyleEndpoint;if(!this.bucket)throw Error("S3 bucket name is required")}async getClient(){if(this._client)return this._client;if(!this._clientPromise)this._clientPromise=import("@stacksjs/ts-cloud").then((cloud)=>{this._client=new cloud.S3Client(this.region,void 0,resolveS3ClientOptions({endpoint:this.endpoint,usePathStyleEndpoint:this.usePathStyleEndpoint,credentials:this.credentials}));return this._client});return this._clientPromise}resolveCredentials(){if(this.credentials?.accessKeyId&&this.credentials.secretAccessKey)return this.credentials;const accessKeyId=process.env.AWS_ACCESS_KEY_ID,secretAccessKey=process.env.AWS_SECRET_ACCESS_KEY,sessionToken=process.env.AWS_SESSION_TOKEN;if(!accessKeyId||!secretAccessKey)throw Error("[storage/s3] presignedUploadPolicy requires AWS credentials \u2014 "+"pass them via S3DiskConfig.credentials or set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY.");return{accessKeyId,secretAccessKey,sessionToken}}prefixPath(path){if(!this.prefix)return path;return`${this.prefix}/${path}`.replace(/\/+/g,"/")}stripPrefix(path){if(!this.prefix)return path;const prefixWithSlash=`${this.prefix}/`;return path.startsWith(prefixWithSlash)?path.slice(prefixWithSlash.length):path}async contentsToBuffer(contents){if(typeof contents==="string")return Buffer.from(contents,"utf8");else if(contents instanceof Buffer)return contents;else if(contents instanceof Uint8Array)return Buffer.from(contents);else{if(typeof contents.getReader!=="function")throw TypeError("[storage/s3] contents must be a web-standard ReadableStream (with .getReader()), not a Node stream.Readable. Convert via Readable.toWeb(nodeStream) before passing.");const reader=contents.getReader(),chunks=[];while(!0){const{done,value}=await reader.read();if(done)break;if(value)chunks.push(value)}return Buffer.concat(chunks.map((c)=>Buffer.from(c)))}}async write(path,contents){const key=this.prefixPath(path),body=await this.contentsToBuffer(contents),contentType=this.detectMimeType(path);await(await this.getClient()).putObject({bucket:this.bucket,key,body,contentType});return{path,size:body.length,contentType,lastModified:Date.now()}}async read(path){const key=this.prefixPath(path),response=await(await this.getClient()).getObjectBytes(this.bucket,key);if(!response?.body)throw Error(`Failed to read file: ${path}`);return Buffer.from(response.body)}async getStream(path,_options){const key=this.prefixPath(path),buf=await(await this.getClient()).getObjectBuffer(this.bucket,key);if(!buf)throw Error(`Failed to read file: ${path}`);const bytes=new Uint8Array(buf);return new ReadableStream({start(controller){controller.enqueue(bytes);controller.close()}})}async putStream(path,stream,options){const key=this.prefixPath(path),contentType=options?.contentType??this.detectMimeType(path),partSize=clampPartSize(options?.partSize??5242880),concurrency=Math.max(1,Math.min(options?.concurrency??4,100)),maxRetries=Math.max(0,options?.maxRetries??3),signal=options?.signal,reader=stream.getReader();let firstChunk=null,firstDone=!1;{const buf=new ChunkBuffer(partSize);while(!firstDone&&buf.length<partSize){if(signal?.aborted){try{reader.releaseLock()}catch{}throw Error("aborted")}const{value,done}=await reader.read();if(done){firstDone=!0;break}if(value)buf.push(value)}firstChunk=buf.flush()}if(firstDone){try{reader.releaseLock()}catch{}await(await this.getClient()).putObject({bucket:this.bucket,key,body:Buffer.from(firstChunk),contentType});return{path,size:firstChunk.length,contentType,lastModified:Date.now()}}const{UploadId:uploadId}=await(await this.getClient()).createMultipartUpload(this.bucket,key,{contentType}),completedParts=[];let totalBytes=0,partNumber=1;const inflight=[],uploadOne=async(body,n)=>{let attempt=0;while(!0){if(signal?.aborted)throw Error("aborted");try{const{ETag}=await(await this.getClient()).uploadPart(this.bucket,key,uploadId,n,Buffer.from(body));completedParts.push({PartNumber:n,ETag});totalBytes+=body.length;return}catch(err){if(attempt>=maxRetries)throw err;attempt+=1}}};try{inflight.push(uploadOne(firstChunk,partNumber++));firstChunk=null;const buf=new ChunkBuffer(partSize);while(!0){if(signal?.aborted)throw Error("aborted");const{value,done}=await reader.read();if(done)break;if(value)buf.push(value);while(buf.length>=partSize){const part=buf.take(partSize);if(inflight.length>=concurrency){await Promise.race(inflight.map((p,i)=>p.then(()=>i)));for(let i=inflight.length-1;i>=0;i--)if(await isSettled(inflight[i]))inflight.splice(i,1)}inflight.push(uploadOne(part,partNumber++))}}try{reader.releaseLock()}catch{}const tail=buf.flush();if(tail.length>0)inflight.push(uploadOne(tail,partNumber++));await Promise.all(inflight);completedParts.sort((a,b)=>a.PartNumber-b.PartNumber);await(await this.getClient()).completeMultipartUpload(this.bucket,key,uploadId,completedParts);return{path,size:totalBytes,contentType,lastModified:Date.now()}}catch(err){try{await(await this.getClient()).abortMultipartUpload(this.bucket,key,uploadId)}catch{}throw err}}async readToString(path){const key=this.prefixPath(path),response=await(await this.getClient()).getObject(this.bucket,key);if(!response)throw Error(`Failed to read file: ${path}`);return response}async readToBuffer(path){return await this.read(path)}async readToUint8Array(path){const buffer=await this.readToBuffer(path);return new Uint8Array(buffer)}async deleteFile(path){const key=this.prefixPath(path);await(await this.getClient()).deleteObject(this.bucket,key)}async deleteDirectory(path){const prefix=this.prefixPath(path),normalizedPrefix=prefix.endsWith("/")?prefix:`${prefix}/`,keys=(await(await this.getClient()).listAllObjects({bucket:this.bucket,prefix:normalizedPrefix})).map((obj)=>obj.Key).filter((k)=>typeof k==="string");if(keys.length===0)return;await(await this.getClient()).deleteObjects(this.bucket,keys)}async createDirectory(_path){}async moveFile(from,to){await this.copyFile(from,to);await this.deleteFile(from)}async copyFile(from,to){const fromKey=this.prefixPath(from),toKey=this.prefixPath(to);await(await this.getClient()).copyObject({sourceBucket:this.bucket,sourceKey:fromKey,destinationBucket:this.bucket,destinationKey:toKey})}async stat(path){const key=this.prefixPath(path),result=await(await this.getClient()).headObject(this.bucket,key);if(!result)throw Error(`File not found: ${path}`);return{path,type:"file",visibility:"private",size:result.ContentLength||0,lastModified:result.LastModified?new Date(result.LastModified).getTime():Date.now(),mimeType:result.ContentType}}list(path,options={}){return this.createAsyncIterator(path,options.deep||!1)}async*createAsyncIterator(path,deep){const prefix=this.prefixPath(path),normalizedPrefix=prefix?`${prefix}/`:void 0;if(deep){const objects=await(await this.getClient()).listAllObjects({bucket:this.bucket,prefix:normalizedPrefix});for(const obj of objects)yield{path:this.stripPrefix(obj.Key),type:"file"}}else{let continuationToken;do{const result=await(await this.getClient()).listObjects({bucket:this.bucket,prefix:normalizedPrefix,continuationToken});for(const obj of result.objects||[])yield{path:this.stripPrefix(obj.Key),type:"file"};continuationToken=result.nextContinuationToken}while(continuationToken)}}async changeVisibility(path,vis){const key=this.prefixPath(path),acl=vis==="public"?"public-read":"private";await(await this.getClient()).putObjectAcl(this.bucket,key,acl)}async visibility(path){const key=this.prefixPath(path);return((await(await this.getClient()).getObjectAcl(this.bucket,key))?.Grants??[]).some((g)=>g.Grantee?.URI==="http://acs.amazonaws.com/groups/global/AllUsers"&&(g.Permission==="READ"||g.Permission==="FULL_CONTROL"))?"public":"private"}async fileExists(path){const key=this.prefixPath(path);try{return!!await(await this.getClient()).headObject(this.bucket,key)}catch(error){if(!error.message?.includes("404")&&!error.message?.includes("NoSuchKey")&&!error.message?.includes("NotFound"))console.debug(`[s3] Unexpected error checking file existence for ${path}: ${error.message}`);return!1}}async directoryExists(path){const prefix=this.prefixPath(path);return((await(await this.getClient()).listObjects({bucket:this.bucket,prefix:`${prefix}/`,maxKeys:1})).objects||[]).length>0}async publicUrl(path,options={}){const key=this.prefixPath(path);return`${options.domain||`https://${this.bucket}.s3.${this.region}.amazonaws.com`}/${key}`}async temporaryUrl(path,options){const key=this.prefixPath(path),expiresIn=Math.floor(normalizeExpiryToMilliseconds(options.expiresIn)/1000),MIN_EXPIRY=60,MAX_EXPIRY=604800;if(!Number.isFinite(expiresIn)||expiresIn<MIN_EXPIRY||expiresIn>MAX_EXPIRY)throw RangeError(`[storage/s3] temporaryUrl expiresIn must be between 60s and 7 days (got ${expiresIn}s)`);return await(await this.getClient()).getSignedUrl({bucket:this.bucket,key,expiresIn,operation:"getObject"})}async signedUrl(path,options){return this.temporaryUrl(path,{expiresIn:options.expiresIn})}async presignedUploadUrl(options){if(!options.contentType)throw Error("[storage/s3] presignedUploadUrl requires `contentType` \u2014 S3 signs against the exact header.");const expiresIn=Math.floor(options.expiresIn),MIN_EXPIRY=60,MAX_EXPIRY=604800;if(!Number.isFinite(expiresIn)||expiresIn<MIN_EXPIRY||expiresIn>MAX_EXPIRY)throw RangeError(`[storage/s3] presignedUploadUrl expiresIn must be between 60s and 7 days (got ${expiresIn}s)`);const safeDir=sanitizePresignedDir(options.dir),safeFilename=options.filename!==void 0?sanitizePresignedFilename(options.filename):`${crypto.randomUUID().replace(/-/g,"")}${this.extensionForContentType(options.contentType)}`,path=safeDir?`${safeDir}/${safeFilename}`:safeFilename,key=this.prefixPath(path);return{url:await(await this.getClient()).getSignedUrl({bucket:this.bucket,key,expiresIn,operation:"putObject"}),path,key,contentType:options.contentType,maxBytes:options.maxBytes}}async presignedUploadPolicy(options){const credentials=this.resolveCredentials(),scopedKey=typeof options.key==="string"?this.prefixPath(options.key):{startsWith:this.prefixPath(options.key.startsWith)};return signS3PresignedPost({bucket:this.bucket,region:this.region,credentials,key:scopedKey,contentType:options.contentType,contentLengthRange:options.contentLengthRange,acl:options.acl,expiresIn:options.expiresIn,fields:options.fields})}extensionForContentType(contentType){const mime=contentType.toLowerCase().split(";")[0]?.trim()??"";return{"image/jpeg":".jpg","image/jpg":".jpg","image/png":".png","image/webp":".webp","image/gif":".gif","image/avif":".avif","image/svg+xml":".svg","application/pdf":".pdf","application/json":".json","application/zip":".zip","text/plain":".txt","text/csv":".csv","video/mp4":".mp4","video/webm":".webm","audio/mpeg":".mp3","audio/wav":".wav"}[mime]??""}async checksum(path,options={}){const algorithm=options.algorithm||"sha256",content=await this.readToUint8Array(path),hasher=new Bun.CryptoHasher(algorithm);hasher.update(content);return hasher.digest("hex")}async mimeType(path,_options={}){return(await this.stat(path)).mimeType||this.detectMimeType(path)}detectMimeType(path){const ext=basename(path).split(".").pop()?.toLowerCase();return{txt:"text/plain",html:"text/html",css:"text/css",js:"application/javascript",json:"application/json",xml:"application/xml",pdf:"application/pdf",zip:"application/zip",jpg:"image/jpeg",jpeg:"image/jpeg",png:"image/png",gif:"image/gif",svg:"image/svg+xml",mp4:"video/mp4",mp3:"audio/mpeg",wav:"audio/wav"}[ext||""]||"application/octet-stream"}async lastModified(path){return(await this.stat(path)).lastModified}async fileSize(path){return(await this.stat(path)).size}}export function createS3Storage(client,config){return new S3StorageAdapter(client,config)}
|
|
1
|
+
import{Buffer}from"node:buffer";import{basename}from"node:path";import{normalizeExpiryToMilliseconds}from"../types";import{sanitizePresignedDir,sanitizePresignedFilename}from"../path-sanitize";import{signS3PresignedPost}from"../s3-presigned-post";import process from"node:process";const S3_MIN_PART_SIZE=5242880,S3_MAX_PART_SIZE=5368709120;function clampPartSize(requested){if(!Number.isFinite(requested))return S3_MIN_PART_SIZE;return Math.max(S3_MIN_PART_SIZE,Math.min(Math.floor(requested),S3_MAX_PART_SIZE))}class ChunkBuffer{chunks=[];total=0;constructor(_partSize){}get length(){return this.total}push(c){this.chunks.push(c);this.total+=c.length}take(n){const out=new Uint8Array(n);let written=0;while(written<n&&this.chunks.length>0){const head=this.chunks[0],need=n-written;if(head.length<=need){out.set(head,written);written+=head.length;this.chunks.shift()}else{out.set(head.subarray(0,need),written);this.chunks[0]=head.subarray(need);written+=need}}this.total-=n;return out}flush(){const out=new Uint8Array(this.total);let off=0;for(const c of this.chunks){out.set(c,off);off+=c.length}this.chunks=[];this.total=0;return out}}async function isSettled(p){const sentinel=Symbol("pending");return await Promise.race([p.then(()=>"settled",()=>"settled"),Promise.resolve(sentinel)])!==sentinel}export function resolveS3ClientOptions(config){const options={};if(config.endpoint)options.endpoint=config.endpoint.replace(/^https?:\/\//i,"").replace(/\/+$/,"");if(config.usePathStyleEndpoint)options.forcePathStyle=!0;if(config.credentials)options.credentials=config.credentials;return Object.keys(options).length>0?options:void 0}export class S3StorageAdapter{_client;_clientPromise=null;bucket;prefix;region;endpoint;usePathStyleEndpoint;credentials;constructor(client,config){this._client=client;this.bucket=config.bucket||"";this.prefix=config.prefix||"";this.region=config.region||"us-east-1";this.credentials=config.credentials;this.endpoint=config.endpoint;this.usePathStyleEndpoint=config.usePathStyleEndpoint;if(!this.bucket)throw Error("S3 bucket name is required")}async getClient(){if(this._client)return this._client;if(!this._clientPromise)this._clientPromise=import("@stacksjs/ts-cloud").then((cloud)=>{this._client=new cloud.S3Client(this.region,void 0,resolveS3ClientOptions({endpoint:this.endpoint,usePathStyleEndpoint:this.usePathStyleEndpoint,credentials:this.credentials}));return this._client});return this._clientPromise}resolveCredentials(){if(this.credentials?.accessKeyId&&this.credentials.secretAccessKey)return this.credentials;const accessKeyId=process.env.AWS_ACCESS_KEY_ID,secretAccessKey=process.env.AWS_SECRET_ACCESS_KEY,sessionToken=process.env.AWS_SESSION_TOKEN;if(!accessKeyId||!secretAccessKey)throw Error("[storage/s3] presignedUploadPolicy requires AWS credentials - pass them via S3DiskConfig.credentials or set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY.");return{accessKeyId,secretAccessKey,sessionToken}}prefixPath(path){if(!this.prefix)return path;return`${this.prefix}/${path}`.replace(/\/+/g,"/")}stripPrefix(path){if(!this.prefix)return path;const prefixWithSlash=`${this.prefix}/`;return path.startsWith(prefixWithSlash)?path.slice(prefixWithSlash.length):path}async contentsToBuffer(contents){if(typeof contents==="string")return Buffer.from(contents,"utf8");else if(contents instanceof Buffer)return contents;else if(contents instanceof Uint8Array)return Buffer.from(contents);else{if(typeof contents.getReader!=="function")throw TypeError("[storage/s3] contents must be a web-standard ReadableStream (with .getReader()), not a Node stream.Readable. Convert via Readable.toWeb(nodeStream) before passing.");const reader=contents.getReader(),chunks=[];while(!0){const{done,value}=await reader.read();if(done)break;if(value)chunks.push(value)}return Buffer.concat(chunks.map((c)=>Buffer.from(c)))}}async write(path,contents){const key=this.prefixPath(path),body=await this.contentsToBuffer(contents),contentType=this.detectMimeType(path);await(await this.getClient()).putObject({bucket:this.bucket,key,body,contentType});return{path,size:body.length,contentType,lastModified:Date.now()}}async read(path){const key=this.prefixPath(path),response=await(await this.getClient()).getObjectBytes(this.bucket,key);if(!response?.body)throw Error(`Failed to read file: ${path}`);return Buffer.from(response.body)}async getStream(path,_options){const key=this.prefixPath(path),buf=await(await this.getClient()).getObjectBuffer(this.bucket,key);if(!buf)throw Error(`Failed to read file: ${path}`);const bytes=new Uint8Array(buf);return new ReadableStream({start(controller){controller.enqueue(bytes);controller.close()}})}async putStream(path,stream,options){const key=this.prefixPath(path),contentType=options?.contentType??this.detectMimeType(path),partSize=clampPartSize(options?.partSize??5242880),concurrency=Math.max(1,Math.min(options?.concurrency??4,100)),maxRetries=Math.max(0,options?.maxRetries??3),signal=options?.signal,reader=stream.getReader();let firstChunk=null,firstDone=!1;{const buf=new ChunkBuffer(partSize);while(!firstDone&&buf.length<partSize){if(signal?.aborted){try{reader.releaseLock()}catch{}throw Error("aborted")}const{value,done}=await reader.read();if(done){firstDone=!0;break}if(value)buf.push(value)}firstChunk=buf.flush()}if(firstDone){try{reader.releaseLock()}catch{}await(await this.getClient()).putObject({bucket:this.bucket,key,body:Buffer.from(firstChunk),contentType});return{path,size:firstChunk.length,contentType,lastModified:Date.now()}}const{UploadId:uploadId}=await(await this.getClient()).createMultipartUpload(this.bucket,key,{contentType}),completedParts=[];let totalBytes=0,partNumber=1;const inflight=[],uploadOne=async(body,n)=>{let attempt=0;while(!0){if(signal?.aborted)throw Error("aborted");try{const{ETag}=await(await this.getClient()).uploadPart(this.bucket,key,uploadId,n,Buffer.from(body));completedParts.push({PartNumber:n,ETag});totalBytes+=body.length;return}catch(err){if(attempt>=maxRetries)throw err;attempt+=1}}};try{inflight.push(uploadOne(firstChunk,partNumber++));firstChunk=null;const buf=new ChunkBuffer(partSize);while(!0){if(signal?.aborted)throw Error("aborted");const{value,done}=await reader.read();if(done)break;if(value)buf.push(value);while(buf.length>=partSize){const part=buf.take(partSize);if(inflight.length>=concurrency){await Promise.race(inflight.map((p,i)=>p.then(()=>i)));for(let i=inflight.length-1;i>=0;i--)if(await isSettled(inflight[i]))inflight.splice(i,1)}inflight.push(uploadOne(part,partNumber++))}}try{reader.releaseLock()}catch{}const tail=buf.flush();if(tail.length>0)inflight.push(uploadOne(tail,partNumber++));await Promise.all(inflight);completedParts.sort((a,b)=>a.PartNumber-b.PartNumber);await(await this.getClient()).completeMultipartUpload(this.bucket,key,uploadId,completedParts);return{path,size:totalBytes,contentType,lastModified:Date.now()}}catch(err){try{await(await this.getClient()).abortMultipartUpload(this.bucket,key,uploadId)}catch{}throw err}}async readToString(path){const key=this.prefixPath(path),response=await(await this.getClient()).getObject(this.bucket,key);if(!response)throw Error(`Failed to read file: ${path}`);return response}async readToBuffer(path){return await this.read(path)}async readToUint8Array(path){const buffer=await this.readToBuffer(path);return new Uint8Array(buffer)}async deleteFile(path){const key=this.prefixPath(path);await(await this.getClient()).deleteObject(this.bucket,key)}async deleteDirectory(path){const prefix=this.prefixPath(path),normalizedPrefix=prefix.endsWith("/")?prefix:`${prefix}/`,keys=(await(await this.getClient()).listAllObjects({bucket:this.bucket,prefix:normalizedPrefix})).map((obj)=>obj.Key).filter((k)=>typeof k==="string");if(keys.length===0)return;await(await this.getClient()).deleteObjects(this.bucket,keys)}async createDirectory(_path){}async moveFile(from,to){await this.copyFile(from,to);await this.deleteFile(from)}async copyFile(from,to){const fromKey=this.prefixPath(from),toKey=this.prefixPath(to);await(await this.getClient()).copyObject({sourceBucket:this.bucket,sourceKey:fromKey,destinationBucket:this.bucket,destinationKey:toKey})}async stat(path){const key=this.prefixPath(path),result=await(await this.getClient()).headObject(this.bucket,key);if(!result)throw Error(`File not found: ${path}`);return{path,type:"file",visibility:"private",size:result.ContentLength||0,lastModified:result.LastModified?new Date(result.LastModified).getTime():Date.now(),mimeType:result.ContentType}}list(path,options={}){return this.createAsyncIterator(path,options.deep||!1)}async*createAsyncIterator(path,deep){const prefix=this.prefixPath(path),normalizedPrefix=prefix?`${prefix}/`:void 0;if(deep){const objects=await(await this.getClient()).listAllObjects({bucket:this.bucket,prefix:normalizedPrefix});for(const obj of objects)yield{path:this.stripPrefix(obj.Key),type:"file"}}else{let continuationToken;do{const result=await(await this.getClient()).listObjects({bucket:this.bucket,prefix:normalizedPrefix,continuationToken});for(const obj of result.objects||[])yield{path:this.stripPrefix(obj.Key),type:"file"};continuationToken=result.nextContinuationToken}while(continuationToken)}}async changeVisibility(path,vis){const key=this.prefixPath(path),acl=vis==="public"?"public-read":"private";await(await this.getClient()).putObjectAcl(this.bucket,key,acl)}async visibility(path){const key=this.prefixPath(path);return((await(await this.getClient()).getObjectAcl(this.bucket,key))?.Grants??[]).some((g)=>g.Grantee?.URI==="http://acs.amazonaws.com/groups/global/AllUsers"&&(g.Permission==="READ"||g.Permission==="FULL_CONTROL"))?"public":"private"}async fileExists(path){const key=this.prefixPath(path);try{return!!await(await this.getClient()).headObject(this.bucket,key)}catch(error){if(!error.message?.includes("404")&&!error.message?.includes("NoSuchKey")&&!error.message?.includes("NotFound"))console.debug(`[s3] Unexpected error checking file existence for ${path}: ${error.message}`);return!1}}async directoryExists(path){const prefix=this.prefixPath(path);return((await(await this.getClient()).listObjects({bucket:this.bucket,prefix:`${prefix}/`,maxKeys:1})).objects||[]).length>0}async publicUrl(path,options={}){const key=this.prefixPath(path);return`${options.domain||`https://${this.bucket}.s3.${this.region}.amazonaws.com`}/${key}`}async temporaryUrl(path,options){const key=this.prefixPath(path),expiresIn=Math.floor(normalizeExpiryToMilliseconds(options.expiresIn)/1000),MIN_EXPIRY=60,MAX_EXPIRY=604800;if(!Number.isFinite(expiresIn)||expiresIn<MIN_EXPIRY||expiresIn>MAX_EXPIRY)throw RangeError(`[storage/s3] temporaryUrl expiresIn must be between 60s and 7 days (got ${expiresIn}s)`);return await(await this.getClient()).getSignedUrl({bucket:this.bucket,key,expiresIn,operation:"getObject"})}async signedUrl(path,options){return this.temporaryUrl(path,{expiresIn:options.expiresIn})}async presignedUploadUrl(options){if(!options.contentType)throw Error("[storage/s3] presignedUploadUrl requires `contentType` - S3 signs against the exact header.");const expiresIn=Math.floor(options.expiresIn),MIN_EXPIRY=60,MAX_EXPIRY=604800;if(!Number.isFinite(expiresIn)||expiresIn<MIN_EXPIRY||expiresIn>MAX_EXPIRY)throw RangeError(`[storage/s3] presignedUploadUrl expiresIn must be between 60s and 7 days (got ${expiresIn}s)`);const safeDir=sanitizePresignedDir(options.dir),safeFilename=options.filename!==void 0?sanitizePresignedFilename(options.filename):`${crypto.randomUUID().replace(/-/g,"")}${this.extensionForContentType(options.contentType)}`,path=safeDir?`${safeDir}/${safeFilename}`:safeFilename,key=this.prefixPath(path);return{url:await(await this.getClient()).getSignedUrl({bucket:this.bucket,key,expiresIn,operation:"putObject"}),path,key,contentType:options.contentType,maxBytes:options.maxBytes}}async presignedUploadPolicy(options){const credentials=this.resolveCredentials(),scopedKey=typeof options.key==="string"?this.prefixPath(options.key):{startsWith:this.prefixPath(options.key.startsWith)};return signS3PresignedPost({bucket:this.bucket,region:this.region,credentials,key:scopedKey,contentType:options.contentType,contentLengthRange:options.contentLengthRange,acl:options.acl,expiresIn:options.expiresIn,fields:options.fields})}extensionForContentType(contentType){const mime=contentType.toLowerCase().split(";")[0]?.trim()??"";return{"image/jpeg":".jpg","image/jpg":".jpg","image/png":".png","image/webp":".webp","image/gif":".gif","image/avif":".avif","image/svg+xml":".svg","application/pdf":".pdf","application/json":".json","application/zip":".zip","text/plain":".txt","text/csv":".csv","video/mp4":".mp4","video/webm":".webm","audio/mpeg":".mp3","audio/wav":".wav"}[mime]??""}async checksum(path,options={}){const algorithm=options.algorithm||"sha256",content=await this.readToUint8Array(path),hasher=new Bun.CryptoHasher(algorithm);hasher.update(content);return hasher.digest("hex")}async mimeType(path,_options={}){return(await this.stat(path)).mimeType||this.detectMimeType(path)}detectMimeType(path){const ext=basename(path).split(".").pop()?.toLowerCase();return{txt:"text/plain",html:"text/html",css:"text/css",js:"application/javascript",json:"application/json",xml:"application/xml",pdf:"application/pdf",zip:"application/zip",jpg:"image/jpeg",jpeg:"image/jpeg",png:"image/png",gif:"image/gif",svg:"image/svg+xml",mp4:"video/mp4",mp3:"audio/mpeg",wav:"audio/wav"}[ext||""]||"application/octet-stream"}async lastModified(path){return(await this.stat(path)).lastModified}async fileSize(path){return(await this.stat(path)).size}}export function createS3Storage(client,config){return new S3StorageAdapter(client,config)}
|
package/dist/adapters/scoped.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const DEFAULT_SCOPE_PATTERN=/^[a-z0-9_-]+$/i;export class ScopedStorageAdapter{inner;scope;scopeWithSlash;constructor(inner,options){const pattern=options.scopePattern??DEFAULT_SCOPE_PATTERN,cleaned=String(options.scope).replace(/^\/+|\/+$/g,"");if(!cleaned)throw Error("[storage/scoped] scope is required");if(!pattern.test(cleaned))throw Error(`[storage/scoped] scope '${cleaned}' contains disallowed characters`);if(cleaned.includes("..")||cleaned.includes("/"))throw Error(`[storage/scoped] scope '${cleaned}' cannot contain path separators or traversal`);this.inner=inner;this.scope=cleaned;this.scopeWithSlash=`${cleaned}/`}scopePath(path){if(typeof path!=="string")throw Error("[storage/scoped] path must be a string");if(path.length===0)return this.scope;if(path.includes("\x00"))throw Error("[storage/scoped] path contains a null byte");if(path.startsWith("/")||/^[A-Za-z]:[\\/]/.test(path))throw Error(`[storage/scoped] path '${path}' is absolute
|
|
1
|
+
const DEFAULT_SCOPE_PATTERN=/^[a-z0-9_-]+$/i;export class ScopedStorageAdapter{inner;scope;scopeWithSlash;constructor(inner,options){const pattern=options.scopePattern??DEFAULT_SCOPE_PATTERN,cleaned=String(options.scope).replace(/^\/+|\/+$/g,"");if(!cleaned)throw Error("[storage/scoped] scope is required");if(!pattern.test(cleaned))throw Error(`[storage/scoped] scope '${cleaned}' contains disallowed characters`);if(cleaned.includes("..")||cleaned.includes("/"))throw Error(`[storage/scoped] scope '${cleaned}' cannot contain path separators or traversal`);this.inner=inner;this.scope=cleaned;this.scopeWithSlash=`${cleaned}/`}scopePath(path){if(typeof path!=="string")throw Error("[storage/scoped] path must be a string");if(path.length===0)return this.scope;if(path.includes("\x00"))throw Error("[storage/scoped] path contains a null byte");if(path.startsWith("/")||/^[A-Za-z]:[\\/]/.test(path))throw Error(`[storage/scoped] path '${path}' is absolute - refusing to escape scope`);if(path.split(/[/\\]/).some((s)=>s===".."))throw Error(`[storage/scoped] path '${path}' contains a '..' segment - refusing to escape scope`);return`${this.scope}/${path.replace(/^\/+/,"")}`}unscopePath(path){if(path===this.scope)return"";if(path.startsWith(this.scopeWithSlash))return path.slice(this.scopeWithSlash.length);return path}async write(path,contents){const result=await this.inner.write(this.scopePath(path),contents);return{...result,path:this.unscopePath(result.path)}}async read(path){return this.inner.read(this.scopePath(path))}async readToString(path){return this.inner.readToString(this.scopePath(path))}async readToBuffer(path){return this.inner.readToBuffer(this.scopePath(path))}async readToUint8Array(path){return this.inner.readToUint8Array(this.scopePath(path))}async deleteFile(path){return this.inner.deleteFile(this.scopePath(path))}async deleteDirectory(path){return this.inner.deleteDirectory(this.scopePath(path))}async createDirectory(path){return this.inner.createDirectory(this.scopePath(path))}async moveFile(from,to){return this.inner.moveFile(this.scopePath(from),this.scopePath(to))}async copyFile(from,to){return this.inner.copyFile(this.scopePath(from),this.scopePath(to))}async stat(path){const entry=await this.inner.stat(this.scopePath(path));return{...entry,path:this.unscopePath(entry.path)}}list(path,options){const inner=this.inner.list(this.scopePath(path),options),unscope=this.unscopePath.bind(this);return async function*(){for await(const entry of inner)yield{...entry,path:unscope(entry.path)}}()}async changeVisibility(path,visibility){return this.inner.changeVisibility(this.scopePath(path),visibility)}async visibility(path){return this.inner.visibility(this.scopePath(path))}async fileExists(path){return this.inner.fileExists(this.scopePath(path))}async directoryExists(path){return this.inner.directoryExists(this.scopePath(path))}async publicUrl(path,options){return this.inner.publicUrl(this.scopePath(path),options)}async temporaryUrl(path,options){return this.inner.temporaryUrl(this.scopePath(path),options)}async signedUrl(path,options){if(typeof this.inner.signedUrl!=="function")throw Error("[storage/scoped] wrapped adapter does not support signedUrl");return this.inner.signedUrl(this.scopePath(path),options)}async presignedUploadUrl(options){if(typeof this.inner.presignedUploadUrl!=="function")throw Error("[storage/scoped] wrapped adapter does not support presignedUploadUrl");const scopedDir=options.dir?`${this.scope}/${options.dir.replace(/^\/+/,"")}`:this.scope,result=await this.inner.presignedUploadUrl({...options,dir:scopedDir});return{...result,path:this.unscopePath(result.path),key:this.unscopePath(result.key)}}async presignedUploadPolicy(options){if(typeof this.inner.presignedUploadPolicy!=="function")throw Error("[storage/scoped] wrapped adapter does not support presignedUploadPolicy");const scopedKey=typeof options.key==="string"?this.scopePath(options.key):{startsWith:this.scopePath(options.key.startsWith)},result=await this.inner.presignedUploadPolicy({...options,key:scopedKey});return{...result,key:this.unscopePath(result.key)}}async getStream(path,options){if(typeof this.inner.getStream!=="function")throw Error("[storage/scoped] wrapped adapter does not support getStream");return this.inner.getStream(this.scopePath(path),options)}async putStream(path,stream,options){if(typeof this.inner.putStream!=="function")throw Error("[storage/scoped] wrapped adapter does not support putStream");const result=await this.inner.putStream(this.scopePath(path),stream,options);return{...result,path:this.unscopePath(result.path)}}async checksum(path,options){return this.inner.checksum(this.scopePath(path),options)}async mimeType(path,options){return this.inner.mimeType(this.scopePath(path),options)}async lastModified(path){return this.inner.lastModified(this.scopePath(path))}async fileSize(path){return this.inner.fileSize(this.scopePath(path))}}export function scoped(inner,options){return new ScopedStorageAdapter(inner,options)}
|
package/dist/facade.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{resolve}from"node:path";import process from"node:process";import{filesystems,app as appConfig}from"@stacksjs/config";import{createLocalStorage}from"./adapters/local";import{S3StorageAdapter}from"./adapters/s3";import{parseDiskPath}from"./path-sanitize";import{putUploadedFile}from"./put-file";function buildConfig(){const cwd=process.cwd(),rootDir=filesystems.root||cwd,s3Config=filesystems.s3,appUrl=(appConfig?.url||"").replace(/\/$/,""),config={default:filesystems.driver||"local",disks:{local:{driver:"local",root:resolve(rootDir,"storage/app"),visibility:filesystems.defaultVisibility||"private"},public:{driver:"local",root:resolve(rootDir,"public"),url:appUrl||"/",visibility:"public"}}};if(s3Config?.bucket)config.disks.s3={driver:"s3",bucket:s3Config.bucket,region:s3Config.region||"us-east-1",prefix:s3Config.prefix,endpoint:s3Config.endpoint,url:filesystems.publicUrl?.domain,usePathStyleEndpoint:!!s3Config.endpoint,visibility:filesystems.defaultVisibility||"private",credentials:s3Config.credentials?{key:s3Config.credentials.accessKeyId,secret:s3Config.credentials.secretAccessKey}:void 0};return config}class StorageManager{_config=null;disks=new Map;customConfig=null;get config(){if(!this._config){const builtConfig=buildConfig();this._config=this.customConfig?{default:this.customConfig.default||builtConfig.default,disks:{...builtConfig.disks,...this.customConfig.disks}}:builtConfig}return this._config}init(config){this.customConfig=config;this._config=null;this.disks.clear();return this}disk(name){const diskName=name||this.config.default;if(this.disks.has(diskName))return this.disks.get(diskName);const diskConfig=this.config.disks[diskName];if(!diskConfig){const available=Object.keys(this.config.disks).join(", ");throw Error(`Disk [${diskName}] is not configured. Available: ${available}`)}const adapter=this.createAdapter(diskName,diskConfig);this.disks.set(diskName,adapter);return adapter}createAdapter(name,config){switch(config.driver){case"local":return this.createLocalAdapter(config);case"s3":return this.createS3Adapter(name,config);default:throw Error(`Unsupported driver: ${config.driver}`)}}createLocalAdapter(config){return createLocalStorage({root:config.root,url:config.url})}createS3Adapter(_name,config){return new S3StorageAdapter(null,{bucket:config.bucket,region:config.region,prefix:config.prefix,endpoint:config.endpoint,usePathStyleEndpoint:config.usePathStyleEndpoint,credentials:config.credentials?{accessKeyId:config.credentials.key,secretAccessKey:config.credentials.secret}:void 0})}async put(pathOrFile,contentsOrOpts){if(typeof pathOrFile==="string")return this.disk().write(pathOrFile,contentsOrOpts);return putUploadedFile(this,pathOrFile,contentsOrOpts??{})}async stat(path){return this.disk().stat(path)}async getStream(path,options){const adapter=this.disk();if(typeof adapter.getStream!=="function")throw Error(`[storage] disk '${this.config.default}' does not support getStream
|
|
1
|
+
import{resolve}from"node:path";import process from"node:process";import{filesystems,app as appConfig}from"@stacksjs/config";import{createLocalStorage}from"./adapters/local";import{S3StorageAdapter}from"./adapters/s3";import{parseDiskPath}from"./path-sanitize";import{putUploadedFile}from"./put-file";function buildConfig(){const cwd=process.cwd(),rootDir=filesystems.root||cwd,s3Config=filesystems.s3,appUrl=(appConfig?.url||"").replace(/\/$/,""),config={default:filesystems.driver||"local",disks:{local:{driver:"local",root:resolve(rootDir,"storage/app"),visibility:filesystems.defaultVisibility||"private"},public:{driver:"local",root:resolve(rootDir,"public"),url:appUrl||"/",visibility:"public"}}};if(s3Config?.bucket)config.disks.s3={driver:"s3",bucket:s3Config.bucket,region:s3Config.region||"us-east-1",prefix:s3Config.prefix,endpoint:s3Config.endpoint,url:filesystems.publicUrl?.domain,usePathStyleEndpoint:!!s3Config.endpoint,visibility:filesystems.defaultVisibility||"private",credentials:s3Config.credentials?{key:s3Config.credentials.accessKeyId,secret:s3Config.credentials.secretAccessKey}:void 0};return config}class StorageManager{_config=null;disks=new Map;customConfig=null;get config(){if(!this._config){const builtConfig=buildConfig();this._config=this.customConfig?{default:this.customConfig.default||builtConfig.default,disks:{...builtConfig.disks,...this.customConfig.disks}}:builtConfig}return this._config}init(config){this.customConfig=config;this._config=null;this.disks.clear();return this}disk(name){const diskName=name||this.config.default;if(this.disks.has(diskName))return this.disks.get(diskName);const diskConfig=this.config.disks[diskName];if(!diskConfig){const available=Object.keys(this.config.disks).join(", ");throw Error(`Disk [${diskName}] is not configured. Available: ${available}`)}const adapter=this.createAdapter(diskName,diskConfig);this.disks.set(diskName,adapter);return adapter}createAdapter(name,config){switch(config.driver){case"local":return this.createLocalAdapter(config);case"s3":return this.createS3Adapter(name,config);default:throw Error(`Unsupported driver: ${config.driver}`)}}createLocalAdapter(config){return createLocalStorage({root:config.root,url:config.url})}createS3Adapter(_name,config){return new S3StorageAdapter(null,{bucket:config.bucket,region:config.region,prefix:config.prefix,endpoint:config.endpoint,usePathStyleEndpoint:config.usePathStyleEndpoint,credentials:config.credentials?{accessKeyId:config.credentials.key,secretAccessKey:config.credentials.secret}:void 0})}async put(pathOrFile,contentsOrOpts){if(typeof pathOrFile==="string")return this.disk().write(pathOrFile,contentsOrOpts);return putUploadedFile(this,pathOrFile,contentsOrOpts??{})}async stat(path){return this.disk().stat(path)}async getStream(path,options){const adapter=this.disk();if(typeof adapter.getStream!=="function")throw Error(`[storage] disk '${this.config.default}' does not support getStream - adapter is missing the optional method`);return adapter.getStream(path,options)}async putStream(path,stream,options){const adapter=this.disk();if(typeof adapter.putStream!=="function")throw Error(`[storage] disk '${this.config.default}' does not support putStream - adapter is missing the optional method`);return adapter.putStream(path,stream,options)}async copyAcross(source,dest){const src=parseDiskPath(source),dst=parseDiskPath(dest);if(src.disk===dst.disk){const adapter=this.disk(src.disk);await adapter.copyFile(src.path,dst.path);return adapter.stat(dst.path).then((entry)=>({path:dst.path,size:entry.size,contentType:entry.mimeType,lastModified:entry.lastModified}))}const contents=await this.disk(src.disk).read(src.path);return this.disk(dst.disk).write(dst.path,contents)}async moveAcross(source,dest){const src=parseDiskPath(source),result=await this.copyAcross(source,dest);await this.disk(src.disk).deleteFile(src.path);return result}async get(path){return this.disk().readToString(path)}async exists(path){return this.disk().fileExists(path)}async missing(path){return!await this.exists(path)}async delete(path){return this.disk().deleteFile(path)}async copy(from,to){return this.disk().copyFile(from,to)}async move(from,to){return this.disk().moveFile(from,to)}async url(path){return this.disk().publicUrl(path)}async signedUrl(path,options){const adapter=this.disk();if(typeof adapter.signedUrl!=="function")throw Error(`[storage] disk '${this.config.default}' does not support signedUrl`);return adapter.signedUrl(path,options)}async presignedUploadUrl(options){const adapter=this.disk();if(typeof adapter.presignedUploadUrl!=="function")throw Error(`[storage] disk '${this.config.default}' does not support presignedUploadUrl - only S3-style adapters do. Use \`Storage.put(file, opts)\` for local/proxied uploads.`);return adapter.presignedUploadUrl(options)}async presignedUploadPolicy(options){const adapter=this.disk();if(typeof adapter.presignedUploadPolicy!=="function")throw Error(`[storage] disk '${this.config.default}' does not support presignedUploadPolicy - S3-only. Use \`presignedUploadUrl\` for the PUT-form, or \`Storage.put(file, opts)\` for server-proxied uploads.`);return adapter.presignedUploadPolicy(options)}async size(path){return this.disk().fileSize(path)}async lastModified(path){return this.disk().lastModified(path)}async mimeType(path){return this.disk().mimeType(path)}async checksum(path,algorithm){return this.disk().checksum(path,{algorithm})}async makeDirectory(path){return this.disk().createDirectory(path)}async deleteDirectory(path){return this.disk().deleteDirectory(path)}files(path=""){return this.disk().list(path)}allFiles(path=""){return this.disk().list(path,{deep:!0})}configure(name,config){const currentConfig=this.config;currentConfig.disks[name]=config;this.disks.delete(name);return this}setDefaultDisk(name){if(!this.config.disks[name])throw Error(`Disk [${name}] is not configured`);this.config.default=name;return this}getDiskConfig(name){return this.config.disks[name||this.config.default]}getConfiguredDisks(){return Object.keys(this.config.disks)}getDefaultDisk(){return this.config.default}reset(){this._config=null;this.customConfig=null;this.disks.clear();return this}}export const Storage=new StorageManager;export{StorageManager};
|
package/dist/put-file.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const MIME_TO_EXT={"image/jpeg":"jpg","image/jpg":"jpg","image/png":"png","image/webp":"webp","image/gif":"gif","image/svg+xml":"svg","image/avif":"avif","application/pdf":"pdf","application/json":"json","application/zip":"zip","application/octet-stream":"bin","text/plain":"txt","text/csv":"csv","text/html":"html","video/mp4":"mp4","video/webm":"webm","audio/mpeg":"mp3","audio/wav":"wav"};function extFromOriginalName(name){if(!name)return null;const idx=name.lastIndexOf(".");if(idx<=0||idx===name.length-1)return null;const ext=name.slice(idx+1).toLowerCase();if(!/^[a-z0-9]+$/.test(ext))return null;return ext}function originalNameOf(file){return file.originalName??file.name}function mimetypeOf(file){return file.mimetype??file.mimeType}function deriveExtension(file){const mime=mimetypeOf(file);return extFromOriginalName(originalNameOf(file))??(mime&&MIME_TO_EXT[mime.toLowerCase()])??null}async function readBytes(file){if(file.buffer!==void 0)return file.buffer;if(typeof file.bytes==="function")return await file.bytes();if(typeof file.arrayBuffer==="function")return await file.arrayBuffer();throw Error("UploadedFile is missing both `buffer` and `bytes()`/`arrayBuffer()` accessors
|
|
1
|
+
const MIME_TO_EXT={"image/jpeg":"jpg","image/jpg":"jpg","image/png":"png","image/webp":"webp","image/gif":"gif","image/svg+xml":"svg","image/avif":"avif","application/pdf":"pdf","application/json":"json","application/zip":"zip","application/octet-stream":"bin","text/plain":"txt","text/csv":"csv","text/html":"html","video/mp4":"mp4","video/webm":"webm","audio/mpeg":"mp3","audio/wav":"wav"};function extFromOriginalName(name){if(!name)return null;const idx=name.lastIndexOf(".");if(idx<=0||idx===name.length-1)return null;const ext=name.slice(idx+1).toLowerCase();if(!/^[a-z0-9]+$/.test(ext))return null;return ext}function originalNameOf(file){return file.originalName??file.name}function mimetypeOf(file){return file.mimetype??file.mimeType}function deriveExtension(file){const mime=mimetypeOf(file);return extFromOriginalName(originalNameOf(file))??(mime&&MIME_TO_EXT[mime.toLowerCase()])??null}async function readBytes(file){if(file.buffer!==void 0)return file.buffer;if(typeof file.bytes==="function")return await file.bytes();if(typeof file.arrayBuffer==="function")return await file.arrayBuffer();throw Error("UploadedFile is missing both `buffer` and `bytes()`/`arrayBuffer()` accessors - cannot read file contents.")}function bufferLikeToHash(buffer){const view=buffer instanceof ArrayBuffer?new Uint8Array(buffer):buffer,hasher=new Bun.CryptoHasher("sha256");hasher.update(view);return hasher.digest("hex").slice(0,32)}function sanitizeOriginalName(name){return name.replace(/[/\\]/g,"_").replace(/\.{2,}/g,"_").replace(/[^A-Za-z0-9._-]/g,"_").replace(/_+/g,"_")}async function resolveFilename(file,strategy){if(typeof strategy==="function")return strategy(file);switch(strategy){case"uuid":return crypto.randomUUID().replace(/-/g,"");case"hash":{const bytes=await readBytes(file);return bufferLikeToHash(bytes)}case"original":{const name=originalNameOf(file);return name?sanitizeOriginalName(name):crypto.randomUUID().replace(/-/g,"")}}}function joinPath(...parts){return parts.filter(Boolean).map((p,i)=>i===0?p.replace(/\/+$/,""):p.replace(/^\/+/,"").replace(/\/+$/,"")).filter(Boolean).join("/")}export async function putUploadedFile(manager,file,opts){const disk=manager.disk(opts.disk),baseName=await resolveFilename(file,opts.filename??"uuid"),wantExt=opts.preserveExtension!==!1,baseHasExt=/\.[A-Za-z0-9]+$/.test(baseName),ext=wantExt&&!baseHasExt?deriveExtension(file):null,finalName=ext?`${baseName}.${ext}`:baseName,fullPath=joinPath(opts.dir??"",finalName);if(opts.overwrite===!1&&await disk.fileExists(fullPath))throw Error(`File already exists: ${fullPath}`);const raw=await readBytes(file);let contents=raw instanceof ArrayBuffer?new Uint8Array(raw):raw;if(opts.transform)contents=await opts.transform(contents);const written=await disk.write(fullPath,contents),url=await disk.publicUrl(fullPath);return{...written,path:fullPath,url}}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/storage",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.72.
|
|
5
|
+
"version": "0.72.40",
|
|
6
6
|
"description": "The Stacks file system.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -65,11 +65,11 @@
|
|
|
65
65
|
"ts-images": "^0.2.11"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"@stacksjs/arrays": "0.72.
|
|
68
|
+
"@stacksjs/arrays": "0.72.40",
|
|
69
69
|
"better-dx": "^0.2.24",
|
|
70
|
-
"@stacksjs/error-handling": "0.72.
|
|
71
|
-
"@stacksjs/path": "0.72.
|
|
72
|
-
"@stacksjs/strings": "0.72.
|
|
73
|
-
"@stacksjs/types": "0.72.
|
|
70
|
+
"@stacksjs/error-handling": "0.72.40",
|
|
71
|
+
"@stacksjs/path": "0.72.40",
|
|
72
|
+
"@stacksjs/strings": "0.72.40",
|
|
73
|
+
"@stacksjs/types": "0.72.40"
|
|
74
74
|
}
|
|
75
75
|
}
|