@stacksjs/storage 0.74.33 → 0.74.35

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.
@@ -0,0 +1,65 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import type { ChecksumOptions, DirectoryListing, FileContents, GetStreamOptions, ListOptions, MimeTypeOptions, PresignedUploadUrl, PresignedUploadUrlOptions, PublicUrlOptions, PutResult, PutStreamOptions, SignedUrlOptions, StatEntry, StorageAdapter, TemporaryUrlOptions, Visibility } from '../types';
3
+ /** Create an Azure Blob Storage adapter instance. */
4
+ export declare function createAzureStorage(config: AzureStorageAdapterConfig): AzureBlobStorageAdapter;
5
+ /**
6
+ * Azure Blob Storage adapter (stacksjs/stacks#1896).
7
+ *
8
+ * The third of the three drivers that issue asked for, and the only one that
9
+ * needed a driver at all: R2, GCS, Filebase, Backblaze, Hetzner and MinIO are
10
+ * all S3-compatible and reuse `S3StorageAdapter` behind a different endpoint
11
+ * (see `r2Disk`, `gcsDisk` and friends in `types/filesystem.ts`). Azure has its
12
+ * own REST surface, so this speaks it directly - Shared Key authorization for
13
+ * server-side calls, service SAS for signed URLs, both from `azure-signing.ts`.
14
+ *
15
+ * Three things behave differently from S3, and none of them can be papered
16
+ * over, so they are named here rather than discovered at runtime:
17
+ *
18
+ * 1. **Visibility is a property of the container, not the blob.** Azure has no
19
+ * per-object ACL. `changeVisibility` therefore refuses rather than silently
20
+ * doing nothing, and `visibility()` reports the container's public-access
21
+ * level, which is the honest answer for every blob in it.
22
+ * 2. **There are no directories.** Same as S3: a "folder" is a name prefix, so
23
+ * `createDirectory` is a no-op and `deleteDirectory` deletes by prefix.
24
+ * 3. **A block blob is capped at 50,000 blocks.** Anything over the single-PUT
25
+ * limit goes through Put Block / Put Block List, which is what `putStream`
26
+ * does; with the default 4 MiB block that reaches ~190 GiB.
27
+ */
28
+ export declare interface AzureStorageAdapterConfig {
29
+ account: string
30
+ accountKey?: string
31
+ sasToken?: string
32
+ container: string
33
+ prefix?: string
34
+ url?: string
35
+ endpoint?: string
36
+ }
37
+ export declare class AzureBlobStorageAdapter implements StorageAdapter {
38
+ constructor(config: AzureStorageAdapterConfig);
39
+ write(path: string, contents: FileContents): Promise<PutResult>;
40
+ read(path: string): Promise<FileContents>;
41
+ readToString(path: string): Promise<string>;
42
+ readToBuffer(path: string): Promise<Buffer>;
43
+ readToUint8Array(path: string): Promise<Uint8Array>;
44
+ getStream(path: string, options?: GetStreamOptions): Promise<ReadableStream<Uint8Array>>;
45
+ putStream(path: string, stream: ReadableStream<Uint8Array>, options?: PutStreamOptions): Promise<PutResult>;
46
+ deleteFile(path: string): Promise<void>;
47
+ deleteDirectory(path: string): Promise<void>;
48
+ createDirectory(_path: string): Promise<void>;
49
+ moveFile(from: string, to: string): Promise<void>;
50
+ copyFile(from: string, to: string): Promise<void>;
51
+ stat(path: string): Promise<StatEntry>;
52
+ list(path: string, options?: ListOptions): DirectoryListing;
53
+ changeVisibility(_path: string, _visibility: Visibility): Promise<void>;
54
+ visibility(_path: string): Promise<Visibility>;
55
+ fileExists(path: string): Promise<boolean>;
56
+ directoryExists(path: string): Promise<boolean>;
57
+ publicUrl(path: string, options?: PublicUrlOptions): Promise<string>;
58
+ temporaryUrl(path: string, options: TemporaryUrlOptions): Promise<string>;
59
+ signedUrl(path: string, options: SignedUrlOptions): Promise<string>;
60
+ presignedUploadUrl(options: PresignedUploadUrlOptions): Promise<PresignedUploadUrl>;
61
+ checksum(path: string, options?: ChecksumOptions): Promise<string>;
62
+ mimeType(path: string, _options?: MimeTypeOptions): Promise<string>;
63
+ lastModified(path: string): Promise<number>;
64
+ fileSize(path: string): Promise<number>;
65
+ }
@@ -0,0 +1,2 @@
1
+ import{Buffer}from"node:buffer";import{createHash}from"node:crypto";import{REST_VERSION,serviceSasQuery,sharedKeyAuthorization}from"../azure-signing";import{extensionForContentType,mimeFromExtension}from"../mime-from-extension";import{sanitizePresignedDir,sanitizePresignedFilename}from"../path-sanitize";import{normalizeExpiryToMilliseconds}from"../types";const MAX_SINGLE_PUT_BYTES=268435456,DEFAULT_BLOCK_BYTES=4194304,MAX_BLOCKS=50000,MIN_EXPIRY_SECONDS=60,MAX_EXPIRY_SECONDS=604800;export class AzureBlobStorageAdapter{account;accountKey;sasToken;container;prefix;endpoint;publicBaseUrl;constructor(config){if(!config.account)throw Error("[storage/azure] a storage account name is required");if(!config.container)throw Error("[storage/azure] a container name is required");if(!config.accountKey&&!config.sasToken)throw Error("[storage/azure] one of `accountKey` or `sasToken` is required - there is no anonymous write path, and a read-only disk still has to authorize its reads.");this.account=config.account;this.accountKey=config.accountKey;this.sasToken=config.sasToken?.replace(/^\?/,"");this.container=config.container;this.prefix=config.prefix?.replace(/^\/+|\/+$/g,"")??"";this.endpoint=(config.endpoint??`https://${config.account}.blob.core.windows.net`).replace(/\/+$/,"");this.publicBaseUrl=config.url?.replace(/\/+$/,"")}blobName(path){const clean=path.replace(/^\/+/,"");return this.prefix?`${this.prefix}/${clean}`.replace(/\/{2,}/g,"/"):clean}stripPrefix(blob){if(!this.prefix)return blob;const withSlash=`${this.prefix}/`;return blob.startsWith(withSlash)?blob.slice(withSlash.length):blob}blobUrl(blob,query={}){const segments=blob?blob.split("/").map(encodeURIComponent).join("/"):"",url=new URL(`${this.endpoint}/${this.container}${segments?`/${segments}`:""}`);for(const[name,value]of Object.entries(query))url.searchParams.set(name,value);return url}async request(method,url,options={}){const headers={"x-ms-date":new Date().toUTCString(),"x-ms-version":REST_VERSION,...options.headers};if(options.body!==void 0)headers["Content-Length"]=String(options.body.byteLength);const target=new URL(url);if(this.accountKey)headers.Authorization=sharedKeyAuthorization({account:this.account,accountKey:this.accountKey},{method,url:target,headers});else if(this.sasToken)for(const[name,value]of new URLSearchParams(this.sasToken))target.searchParams.set(name,value);const response=await fetch(target,{method,headers,body:options.body?new Uint8Array(options.body):void 0}),body=await response.arrayBuffer();if(!(options.expect??[200,201,202,204,206]).includes(response.status)){const text=new TextDecoder().decode(body),code=text.match(/<Code>([^<]+)<\/Code>/)?.[1],message=text.match(/<Message>([^<]+)<\/Message>/)?.[1]?.split(`
2
+ `)[0];throw Error(`[storage/azure] ${method} ${target.pathname} failed with ${response.status}${code?` (${code})`:""}${message?`: ${message}`:""}`)}return{status:response.status,headers:response.headers,body}}async contentsToBuffer(contents){if(typeof contents==="string")return Buffer.from(contents,"utf8");if(contents instanceof Buffer)return contents;if(contents instanceof Uint8Array)return Buffer.from(contents);if(typeof contents.getReader!=="function")throw TypeError("[storage/azure] contents must be a web-standard ReadableStream (with .getReader()), not a Node stream.Readable. Convert via Readable.toWeb(nodeStream) before passing.");return Buffer.concat(await collect(contents))}async write(path,contents){const body=await this.contentsToBuffer(contents),contentType=mimeFromExtension(path);if(body.byteLength>MAX_SINGLE_PUT_BYTES)return await this.putStream(path,streamOf(body),{contentType});const response=await this.request("PUT",this.blobUrl(this.blobName(path)),{headers:{"x-ms-blob-type":"BlockBlob","Content-Type":contentType},body,expect:[201]});return{path,size:body.byteLength,contentType,etag:response.headers.get("etag")??void 0,lastModified:lastModifiedFrom(response.headers)}}async read(path){const response=await this.request("GET",this.blobUrl(this.blobName(path)),{expect:[200,206]});return Buffer.from(response.body)}async readToString(path){return(await this.readToBuffer(path)).toString("utf8")}async readToBuffer(path){return Buffer.from(await this.read(path))}async readToUint8Array(path){return new Uint8Array(await this.readToBuffer(path))}async getStream(path,options){const headers={"x-ms-date":new Date().toUTCString(),"x-ms-version":REST_VERSION},url=this.blobUrl(this.blobName(path));if(this.accountKey)headers.Authorization=sharedKeyAuthorization({account:this.account,accountKey:this.accountKey},{method:"GET",url,headers});else if(this.sasToken)for(const[name,value]of new URLSearchParams(this.sasToken))url.searchParams.set(name,value);const response=await fetch(url,{method:"GET",headers,signal:options?.signal});if(!response.ok||!response.body)throw Error(`[storage/azure] failed to open a stream for ${path}: ${response.status}`);return response.body}async putStream(path,stream,options){const blob=this.blobName(path),contentType=options?.contentType??mimeFromExtension(path),blockSize=Math.max(1,Math.min(options?.partSize??DEFAULT_BLOCK_BYTES,4194304000)),reader=stream.getReader(),blockIds=[];let pending=[],pendingBytes=0,total=0;const flush=async()=>{if(pendingBytes===0)return;if(blockIds.length>=MAX_BLOCKS)throw RangeError(`[storage/azure] a block blob holds at most ${MAX_BLOCKS} blocks; raise \`partSize\` above ${blockSize} bytes for an upload this large.`);const id=Buffer.from(String(blockIds.length).padStart(6,"0")).toString("base64"),body=Buffer.concat(pending);pending=[];pendingBytes=0;await this.request("PUT",this.blobUrl(blob,{comp:"block",blockid:id}),{body,expect:[201]});blockIds.push(id)};try{while(!0){options?.signal?.throwIfAborted();const{done,value}=await reader.read();if(done)break;if(!value?.length)continue;total+=value.length;pending.push(Buffer.from(value));pendingBytes+=value.length;if(pendingBytes>=blockSize)await flush()}await flush()}finally{reader.releaseLock()}const list=`<?xml version="1.0" encoding="utf-8"?><BlockList>${blockIds.map((id)=>`<Latest>${id}</Latest>`).join("")}</BlockList>`,response=await this.request("PUT",this.blobUrl(blob,{comp:"blocklist"}),{headers:{"Content-Type":"application/xml","x-ms-blob-content-type":contentType},body:Buffer.from(list,"utf8"),expect:[201]});return{path,size:total,contentType,etag:response.headers.get("etag")??void 0,lastModified:lastModifiedFrom(response.headers)}}async deleteFile(path){await this.request("DELETE",this.blobUrl(this.blobName(path)),{expect:[202]})}async deleteDirectory(path){for await(const entry of this.list(path,{deep:!0}))await this.deleteFile(entry.path)}async createDirectory(_path){}async moveFile(from,to){await this.copyFile(from,to);await this.deleteFile(from)}async copyFile(from,to){const source=this.blobUrl(this.blobName(from)),status=(await this.request("PUT",this.blobUrl(this.blobName(to)),{headers:{"x-ms-copy-source":source.toString()},expect:[202]})).headers.get("x-ms-copy-status");if(status&&status!=="success")throw Error(`[storage/azure] copy of ${from} to ${to} did not complete synchronously (status: ${status})`)}async stat(path){const response=await this.request("HEAD",this.blobUrl(this.blobName(path)),{expect:[200]});return{path,type:"file",visibility:await this.visibility(path),size:Number(response.headers.get("content-length")??0),lastModified:lastModifiedFrom(response.headers),mimeType:response.headers.get("content-type")??void 0}}list(path,options={}){return this.listBlobs(path,options.deep??!1)}async*listBlobs(path,deep){const{XMLParser}=await import("@stacksjs/ts-xml"),parser=new XMLParser({ignoreAttributes:!0,parseTagValue:!1,trimValues:!0}),scoped=this.blobName(path.replace(/\/+$/,"")),prefix=scoped?`${scoped}/`:"";let marker;do{const query={restype:"container",comp:"list"};if(prefix)query.prefix=prefix;if(!deep)query.delimiter="/";if(marker)query.marker=marker;const response=await this.request("GET",this.blobUrl(void 0,query),{expect:[200]}),parsed=parser.parse(new TextDecoder().decode(response.body)),blobs=parsed?.EnumerationResults?.Blobs;for(const blob of asArray(blobs?.Blob)){if(!blob?.Name)continue;yield{path:this.stripPrefix(blob.Name),type:"file"}}for(const folder of asArray(blobs?.BlobPrefix)){if(!folder?.Name)continue;yield{path:this.stripPrefix(folder.Name.replace(/\/+$/,"")),type:"directory"}}marker=parsed?.EnumerationResults?.NextMarker||void 0}while(marker)}async changeVisibility(_path,_visibility){throw Error("[storage/azure] visibility is a property of the container, not the blob - Azure has no per-blob ACL. Set the container's public access level in Azure, or hand out a time-limited URL with `signedUrl()`.")}async visibility(_path){const level=(await this.request("GET",this.blobUrl(void 0,{restype:"container",comp:"acl"}),{expect:[200]})).headers.get("x-ms-blob-public-access");return level==="container"||level==="blob"?"public":"private"}async fileExists(path){try{await this.request("HEAD",this.blobUrl(this.blobName(path)),{expect:[200]});return!0}catch{return!1}}async directoryExists(path){for await(const _entry of this.list(path,{deep:!0}))return!0;return!1}async publicUrl(path,options={}){const blob=this.blobName(path),base=options.domain?.replace(/\/+$/,"")??this.publicBaseUrl;if(base)return`${base}/${blob}`;return this.blobUrl(blob).toString()}async temporaryUrl(path,options){return this.signUrl(path,Math.floor(normalizeExpiryToMilliseconds(options.expiresIn)/1000),"r")}async signedUrl(path,options){return this.signUrl(path,Math.floor(normalizeExpiryToMilliseconds(options.expiresIn)/1000),"r")}async presignedUploadUrl(options){if(!options.contentType)throw Error("[storage/azure] presignedUploadUrl requires `contentType` - the SAS signs against it.");const safeDir=sanitizePresignedDir(options.dir),safeFilename=options.filename!==void 0?sanitizePresignedFilename(options.filename):`${crypto.randomUUID().replace(/-/g,"")}${extensionForContentType(options.contentType)}`,path=safeDir?`${safeDir}/${safeFilename}`:safeFilename;return{url:await this.signUrl(path,Math.floor(options.expiresIn),"cw",options.contentType),path,key:this.blobName(path),contentType:options.contentType,maxBytes:options.maxBytes}}async signUrl(path,expiresIn,permissions,contentType){if(!this.accountKey)throw Error("[storage/azure] signing a URL needs the account key. This disk is configured with a SAS token, and re-serving that token would hand out its full grant rather than a scoped one.");if(!Number.isFinite(expiresIn)||expiresIn<MIN_EXPIRY_SECONDS||expiresIn>MAX_EXPIRY_SECONDS)throw RangeError(`[storage/azure] expiresIn must be between 60s and 7 days (got ${expiresIn}s)`);const query=serviceSasQuery({account:this.account,accountKey:this.accountKey},{container:this.container,blob:this.blobName(path),permissions,expiresAt:new Date(Date.now()+expiresIn*1000),contentType});return`${this.blobUrl(this.blobName(path)).toString()}?${query}`}async checksum(path,options={}){const contents=await this.readToBuffer(path);return createHash(options.algorithm||"sha256").update(contents).digest("hex")}async mimeType(path,_options={}){return(await this.stat(path)).mimeType??mimeFromExtension(path)}async lastModified(path){return(await this.stat(path)).lastModified}async fileSize(path){return(await this.stat(path)).size}}function asArray(value){if(value===void 0||value===null)return[];return Array.isArray(value)?value:[value]}function lastModifiedFrom(headers){const raw=headers.get("last-modified"),parsed=raw?Date.parse(raw):Number.NaN;return Number.isNaN(parsed)?Date.now():parsed}async function collect(stream){const reader=stream.getReader(),chunks=[];while(!0){const{done,value}=await reader.read();if(done)break;if(value)chunks.push(Buffer.from(value))}return chunks}function streamOf(body){return new ReadableStream({start(controller){controller.enqueue(new Uint8Array(body));controller.close()}})}export function createAzureStorage(config){return new AzureBlobStorageAdapter(config)}
@@ -1,3 +1,5 @@
1
+ // Azure Blob Storage - the one provider with no S3-compatible API (stacksjs/stacks#1896)
2
+ export * from './azure';
1
3
  export * from './local';
2
4
  export * from './memory';
3
5
  export * from './s3';
@@ -1 +1 @@
1
- export*from"./local";export*from"./memory";export*from"./s3";export*from"./bun";export*from"./scoped";
1
+ export*from"./azure";export*from"./local";export*from"./memory";export*from"./s3";export*from"./bun";export*from"./scoped";
@@ -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 - 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}
1
+ import{Buffer}from"node:buffer";import{mimeFromExtension}from"../mime-from-extension";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){return mimeFromExtension(path)}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}
@@ -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;publicBaseUrl;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;this.publicBaseUrl=config.url?.replace(/\/+$/,"");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),base=options.domain?.replace(/\/+$/,"")??this.publicBaseUrl;if(base)return`${base}/${key}`;if(this.endpoint)return`${this.endpointBaseUrl()}/${key}`;return`https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`}endpointBaseUrl(){const endpoint=this.endpoint.replace(/\/+$/,""),withScheme=/^https?:\/\//.test(endpoint)?endpoint:`https://${endpoint}`;if(this.usePathStyleEndpoint)return`${withScheme}/${this.bucket}`;const url=new URL(withScheme);url.hostname=`${this.bucket}.${url.hostname}`;return url.origin}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)}
1
+ import{Buffer}from"node:buffer";import{extensionForContentType,mimeFromExtension}from"../mime-from-extension";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;publicBaseUrl;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;this.publicBaseUrl=config.url?.replace(/\/+$/,"");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),base=options.domain?.replace(/\/+$/,"")??this.publicBaseUrl;if(base)return`${base}/${key}`;if(this.endpoint)return`${this.endpointBaseUrl()}/${key}`;return`https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`}endpointBaseUrl(){const endpoint=this.endpoint.replace(/\/+$/,""),withScheme=/^https?:\/\//.test(endpoint)?endpoint:`https://${endpoint}`;if(this.usePathStyleEndpoint)return`${withScheme}/${this.bucket}`;const url=new URL(withScheme);url.hostname=`${this.bucket}.${url.hostname}`;return url.origin}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){return extensionForContentType(contentType)}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){return mimeFromExtension(path)}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)}
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The `x-ms-*` headers, folded into the form the string-to-sign expects:
3
+ * lowercased names, sorted lexicographically, whitespace collapsed out of the
4
+ * values, one `name:value` per line.
5
+ *
6
+ * Sorting is by the header name only, and by ordinal comparison rather than
7
+ * locale - `localeCompare` orders `x-ms-blob-type` against `x-ms-date`
8
+ * differently under some locales, which is a signature that works on one
9
+ * machine and fails on another.
10
+ */
11
+ export declare function canonicalizedHeaders(headers: Record<string, string | undefined>): string;
12
+ /**
13
+ * The canonicalized resource: the account-scoped path, then every query
14
+ * parameter, lowercased and sorted, one per line.
15
+ *
16
+ * Repeated parameters are joined with commas after sorting their values, which
17
+ * is what the specification calls for and what makes `?include=metadata&include=snapshots`
18
+ * sign the same way whatever order the caller built it in.
19
+ */
20
+ export declare function canonicalizedResource(account: string, url: string | URL): string;
21
+ /**
22
+ * The Shared Key string-to-sign.
23
+ *
24
+ * The blank lines are load bearing: the format is positional, with one line per
25
+ * header whether or not the request carries it, so a dropped empty line shifts
26
+ * every field after it. `Content-Length` is the one exception to "send what you
27
+ * have" - it is sent as an empty string when zero, because a literal `0` was
28
+ * what versions before 2015-02-21 signed and Azure has read it as empty ever
29
+ * since.
30
+ */
31
+ export declare function sharedKeyStringToSign(account: string, request: AzureRequestToSign): string;
32
+ /**
33
+ * The `Authorization` header value for a Shared Key request.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * headers.Authorization = sharedKeyAuthorization(
38
+ * { account: 'acct', accountKey },
39
+ * { method: 'PUT', url, headers },
40
+ * )
41
+ * ```
42
+ */
43
+ export declare function sharedKeyAuthorization(credential: AzureSharedKeyCredential, request: AzureRequestToSign): string;
44
+ /**
45
+ * Sort permission letters into the order Azure requires, dropping duplicates
46
+ * and anything unrecognised.
47
+ *
48
+ * The service compares the permission string against a fixed order rather than
49
+ * as a set, so `wr` is not `rw`; it answers `AuthorizationPermissionMismatch`,
50
+ * which reads like the SAS lacks the permission it plainly names.
51
+ */
52
+ export declare function orderSasPermissions(permissions: string): string;
53
+ /** Azure's SAS timestamp format: UTC, second precision, no milliseconds. */
54
+ export declare function sasTimestamp(date: Date): string;
55
+ /**
56
+ * Build and sign a service SAS, returning it as a query string.
57
+ *
58
+ * The field list is positional and version-specific; see {@link SAS_VERSION}.
59
+ * Fields this module does not offer (`signedIdentifier`, `signedSnapshotTime`,
60
+ * `signedEncryptionScope`) are still signed, as empty lines, because their
61
+ * absence is part of what the service recomputes.
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * const query = serviceSasQuery(
66
+ * { account: 'acct', accountKey },
67
+ * { container: 'uploads', blob: 'reports/q1.pdf', permissions: 'r', expiresAt },
68
+ * )
69
+ * const url = `https://acct.blob.core.windows.net/uploads/reports/q1.pdf?${query}`
70
+ * ```
71
+ */
72
+ export declare function serviceSasQuery(credential: AzureSharedKeyCredential, options: ServiceSasOptions): string;
73
+ /**
74
+ * The service SAS version this module signs for.
75
+ *
76
+ * The string-to-sign gained fields over time, so the version is not cosmetic -
77
+ * signing 2020-12-06's field list and declaring an older `sv` produces a
78
+ * signature Azure computes differently and rejects. 2020-12-06 is the first
79
+ * version carrying `signedEncryptionScope`, and is accepted by every currently
80
+ * supported storage account.
81
+ */
82
+ export declare const SAS_VERSION: '2020-12-06';
83
+ /** The REST API version sent on Shared Key requests. */
84
+ export declare const REST_VERSION: '2021-08-06';
85
+ /** Credentials for Shared Key authorization: the account and one of its keys. */
86
+ export declare interface AzureSharedKeyCredential {
87
+ account: string
88
+ accountKey: string
89
+ }
90
+ /** A request to authorize, reduced to what the string-to-sign reads. */
91
+ export declare interface AzureRequestToSign {
92
+ method: string
93
+ url: string | URL
94
+ headers: Record<string, string | undefined>
95
+ }
96
+ /** What a service SAS grants, and for how long. */
97
+ export declare interface ServiceSasOptions {
98
+ container: string
99
+ blob?: string
100
+ permissions: string
101
+ startsAt?: Date
102
+ expiresAt: Date
103
+ protocol?: 'https' | 'https,http'
104
+ ip?: string
105
+ contentDisposition?: string
106
+ contentType?: string
107
+ }
@@ -0,0 +1,6 @@
1
+ import{Buffer}from"node:buffer";import{createHmac}from"node:crypto";export const SAS_VERSION="2020-12-06",REST_VERSION="2021-08-06";function hmacBase64(keyBase64,data){return createHmac("sha256",Buffer.from(keyBase64,"base64")).update(data,"utf8").digest("base64")}function header(headers,name){const wanted=name.toLowerCase();for(const[key,value]of Object.entries(headers))if(key.toLowerCase()===wanted)return value??"";return""}export function canonicalizedHeaders(headers){return Object.entries(headers).filter(([name,value])=>name.toLowerCase().startsWith("x-ms-")&&value!==void 0&&value!=="").map(([name,value])=>[name.toLowerCase(),String(value).replace(/\s+/g," ").trim()]).sort((a,b)=>a[0]<b[0]?-1:a[0]>b[0]?1:0).map(([name,value])=>`${name}:${value}
2
+ `).join("")}export function canonicalizedResource(account,url){const parsed=typeof url==="string"?new URL(url):url,grouped=new Map;for(const[name,value]of parsed.searchParams){const key=name.toLowerCase(),existing=grouped.get(key);if(existing)existing.push(value);else grouped.set(key,[value])}const lines=[...grouped.entries()].sort((a,b)=>a[0]<b[0]?-1:a[0]>b[0]?1:0).map(([name,values])=>`${name}:${values.slice().sort().join(",")}`);return[`/${account}${parsed.pathname}`,...lines].join(`
3
+ `)}export function sharedKeyStringToSign(account,request){const{headers}=request,contentLength=header(headers,"content-length");return[request.method.toUpperCase(),header(headers,"content-encoding"),header(headers,"content-language"),contentLength==="0"?"":contentLength,header(headers,"content-md5"),header(headers,"content-type"),header(headers,"date"),header(headers,"if-modified-since"),header(headers,"if-match"),header(headers,"if-none-match"),header(headers,"if-unmodified-since"),header(headers,"range")].join(`
4
+ `)+`
5
+ ${canonicalizedHeaders(headers)}${canonicalizedResource(account,request.url)}`}export function sharedKeyAuthorization(credential,request){const signature=hmacBase64(credential.accountKey,sharedKeyStringToSign(credential.account,request));return`SharedKey ${credential.account}:${signature}`}const SAS_PERMISSION_ORDER="racwdxltmeop";export function orderSasPermissions(permissions){const wanted=new Set(permissions.toLowerCase());return[...SAS_PERMISSION_ORDER].filter((letter)=>wanted.has(letter)).join("")}export function sasTimestamp(date){return`${date.toISOString().slice(0,19)}Z`}export function serviceSasQuery(credential,options){const permissions=orderSasPermissions(options.permissions),start=options.startsAt?sasTimestamp(options.startsAt):"",expiry=sasTimestamp(options.expiresAt),protocol=options.protocol??"https",resource=options.blob?"b":"c",ip=options.ip??"",path=options.blob?`/blob/${credential.account}/${options.container}/${options.blob}`:`/blob/${credential.account}/${options.container}`,stringToSign=[permissions,start,expiry,path,"",ip,protocol,SAS_VERSION,resource,"","","",options.contentDisposition??"","","",options.contentType??""].join(`
6
+ `),signature=hmacBase64(credential.accountKey,stringToSign),query=new URLSearchParams;query.set("sv",SAS_VERSION);query.set("sr",resource);query.set("sp",permissions);if(start)query.set("st",start);query.set("se",expiry);query.set("spr",protocol);if(ip)query.set("sip",ip);if(options.contentDisposition)query.set("rscd",options.contentDisposition);if(options.contentType)query.set("rsct",options.contentType);query.set("sig",signature);return query.toString()}
package/dist/facade.d.ts CHANGED
@@ -1,7 +1,8 @@
1
- import type { DiskConfig, DiskName, FilesystemConfig, LocalDiskConfig, S3DiskConfig } from './types/filesystem';
1
+ import type { AzureDiskConfig, DiskConfig, DiskName, FilesystemConfig, LocalDiskConfig, S3DiskConfig } from './types/filesystem';
2
2
  import type { GetStreamOptions, PresignedUploadPolicy, PresignedUploadPolicyOptions, PresignedUploadUrl, PresignedUploadUrlOptions, PutResult, PutStreamOptions, SignedUrlOptions, StatEntry, StorageAdapter } from './types';
3
3
  import type { PutFileOptions, UploadedFileLike } from './put-file';
4
4
  export type {
5
+ AzureDiskConfig,
5
6
  DiskConfig,
6
7
  FilesystemConfig,
7
8
  LocalDiskConfig,
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}.${diskName==="s3"?" Set `filesystems.s3.bucket` in config/filesystems.ts - the s3 disk is only built once a bucket is configured.":name===void 0?" This is the DEFAULT disk, from `filesystems.driver`; it has to name one of the disks above.":""}`)}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,url:config.url,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};
1
+ import{resolve}from"node:path";import process from"node:process";import{filesystems,app as appConfig}from"@stacksjs/config";import{AzureBlobStorageAdapter}from"./adapters/azure";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}.${diskName==="s3"?" Set `filesystems.s3.bucket` in config/filesystems.ts - the s3 disk is only built once a bucket is configured.":name===void 0?" This is the DEFAULT disk, from `filesystems.driver`; it has to name one of the disks above.":""}`)}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);case"azure":return this.createAzureAdapter(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,url:config.url,credentials:config.credentials?{accessKeyId:config.credentials.key,secretAccessKey:config.credentials.secret}:void 0})}createAzureAdapter(config){return new AzureBlobStorageAdapter({account:config.account,accountKey:config.accountKey,sasToken:config.sasToken,container:config.container,prefix:config.prefix,url:config.url,endpoint:config.endpoint})}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/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type { ProvisionOptions, ProvisionResult } from './provision';
2
2
  export type { ServeFileOptions } from './static-serve';
3
- export type { DiskConfig, FilesystemConfig, LocalDiskConfig, S3DiskConfig } from './facade';
3
+ export type { AzureDiskConfig, DiskConfig, FilesystemConfig, LocalDiskConfig, S3DiskConfig } from './facade';
4
4
  export type { FilenameStrategy, PutFileOptions, UploadedFileLike } from './put-file';
5
5
  // Disk-name autocomplete: userland augments `KnownDisks` to get
6
6
  // completion on `Storage.disk('…')` (stacksjs/stacks#1924).
@@ -38,7 +38,7 @@ export { serveFile } from './static-serve';
38
38
  export { Storage, StorageManager } from './facade';
39
39
  export { UploadedFile, uploadedFile, uploadedFiles } from './uploaded-file';
40
40
  // Filesystem type helpers
41
- export { backblazeDisk, configFromEnv, filebaseDisk, gcsDisk, hetznerDisk, localDisk, r2Disk, s3Disk } from './types/filesystem';
41
+ export { azureDisk, backblazeDisk, configFromEnv, filebaseDisk, gcsDisk, hetznerDisk, localDisk, r2Disk, s3Disk } from './types/filesystem';
42
42
  // Signed-URL helpers (used by Storage.disk('local').signedUrl())
43
43
  export {
44
44
  clearRevokedSignedStorageTokens,
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export*from"./copy";export*from"./delete";export*from"./files";export*from"./folders";export*from"./fs";export*from"./glob";export*from"./hash";export*from"./helpers";export* as storage from"./storage";export*from"./zip";export{ensureBucket,ensureConfiguredBuckets}from"./provision";export*from"./adapters";export*from"./types";export*from"./drivers";export{serveFile}from"./static-serve";export{Storage,StorageManager}from"./facade";export{UploadedFile,uploadedFile,uploadedFiles}from"./uploaded-file";export{backblazeDisk,configFromEnv,filebaseDisk,gcsDisk,hetznerDisk,localDisk,r2Disk,s3Disk}from"./types/filesystem";export{clearRevokedSignedStorageTokens,createSignedStorageToken,isSignedStorageTokenRevoked,revokeSignedStorageToken,verifySignedStorageToken}from"./signed-url";export{parseDiskPath,PathSanitizeError,sanitizePresignedDir,sanitizePresignedFilename}from"./path-sanitize";export{detectMimeFromMagicBytes,verifyUploadedMime}from"./mime-verify";export{signS3PresignedPost}from"./s3-presigned-post";
1
+ export*from"./copy";export*from"./delete";export*from"./files";export*from"./folders";export*from"./fs";export*from"./glob";export*from"./hash";export*from"./helpers";export* as storage from"./storage";export*from"./zip";export{ensureBucket,ensureConfiguredBuckets}from"./provision";export*from"./adapters";export*from"./types";export*from"./drivers";export{serveFile}from"./static-serve";export{Storage,StorageManager}from"./facade";export{UploadedFile,uploadedFile,uploadedFiles}from"./uploaded-file";export{azureDisk,backblazeDisk,configFromEnv,filebaseDisk,gcsDisk,hetznerDisk,localDisk,r2Disk,s3Disk}from"./types/filesystem";export{clearRevokedSignedStorageTokens,createSignedStorageToken,isSignedStorageTokenRevoked,revokeSignedStorageToken,verifySignedStorageToken}from"./signed-url";export{parseDiskPath,PathSanitizeError,sanitizePresignedDir,sanitizePresignedFilename}from"./path-sanitize";export{detectMimeFromMagicBytes,verifyUploadedMime}from"./mime-verify";export{signS3PresignedPost}from"./s3-presigned-post";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Guess a content type from a path's extension, falling back to
3
+ * `application/octet-stream`.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * mimeFromExtension('reports/q1.pdf') // 'application/pdf'
8
+ * mimeFromExtension('archive.tar.gz') // 'application/octet-stream'
9
+ * ```
10
+ */
11
+ export declare function mimeFromExtension(path: string): string;
12
+ /**
13
+ * The extension for a content type, or `''` when there is no obvious one.
14
+ *
15
+ * Parameters are stripped first, so `text/plain; charset=utf-8` answers the
16
+ * same as `text/plain`.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * extensionForContentType('image/png') // '.png'
21
+ * extensionForContentType('text/csv; charset=utf-8') // '.csv'
22
+ * extensionForContentType('application/x-thing') // ''
23
+ * ```
24
+ */
25
+ export declare function extensionForContentType(contentType: string): string;
@@ -0,0 +1 @@
1
+ import{basename}from"node:path";const MIME_TYPES={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"};export function mimeFromExtension(path){const ext=basename(path).split(".").pop()?.toLowerCase();return MIME_TYPES[ext||""]||"application/octet-stream"}const EXTENSIONS={"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"};export function extensionForContentType(contentType){const mime=contentType.toLowerCase().split(";")[0]?.trim()??"";return EXTENSIONS[mime]??""}
@@ -76,6 +76,24 @@ export declare function gcsDisk(bucket: string, options?: Partial<Omit<S3DiskCon
76
76
  * `usePathStyleEndpoint: false` to opt out.
77
77
  */
78
78
  export declare function hetznerDisk(bucket: string, location?: HetznerLocation, options?: Partial<Omit<S3DiskConfig, 'driver' | 'bucket'>>): S3DiskConfig;
79
+ /**
80
+ * Helper to create an Azure Blob Storage disk config (stacksjs/stacks#1896).
81
+ *
82
+ * The one provider here that is not S3-compatible, so it uses the `azure`
83
+ * driver rather than an endpoint on the `s3` one. Supply the account key via
84
+ * `options.accountKey` or `AZURE_STORAGE_ACCOUNT_KEY`.
85
+ *
86
+ * Two things behave differently from every S3 disk above, and neither can be
87
+ * emulated:
88
+ *
89
+ * - **Visibility is a container property.** Azure has no per-blob ACL, so
90
+ * `changeVisibility()` refuses instead of silently doing nothing; a
91
+ * per-object grant on Azure is a SAS, which is what `signedUrl()` mints.
92
+ * - **`endpoint` carries the account for Azurite.** The local emulator serves
93
+ * `http://127.0.0.1:10000/devstoreaccount1`, path-scoped rather than
94
+ * subdomain-scoped, which is exactly what that option is for.
95
+ */
96
+ export declare function azureDisk(container: string, account: string, options?: Partial<Omit<AzureDiskConfig, 'driver' | 'container' | 'account'>>): AzureDiskConfig;
79
97
  /**
80
98
  * Create filesystem config from environment variables
81
99
  */
@@ -112,6 +130,23 @@ export declare interface S3DiskConfig extends BaseDiskConfig {
112
130
  secret: string
113
131
  }
114
132
  }
133
+ /**
134
+ * Azure Blob Storage disk configuration (stacksjs/stacks#1896).
135
+ *
136
+ * Its own driver rather than an `s3` disk with an endpoint, because Azure is
137
+ * the one provider in that issue with no S3-compatible API - R2, GCS, Filebase,
138
+ * Backblaze, Hetzner and MinIO all reuse `s3`.
139
+ */
140
+ export declare interface AzureDiskConfig extends BaseDiskConfig {
141
+ driver: 'azure'
142
+ account: string
143
+ container: string
144
+ accountKey?: string
145
+ sasToken?: string
146
+ prefix?: string
147
+ url?: string
148
+ endpoint?: string
149
+ }
115
150
  /**
116
151
  * Userland-augmentable disk-name registry (stacksjs/stacks#1924).
117
152
  *
@@ -182,14 +217,15 @@ export declare interface FilesystemEnv {
182
217
  * Filesystem Configuration Types
183
218
  *
184
219
  * Laravel-style filesystem configuration with clean, typed interfaces.
185
- * Supports local, public, and S3 disk drivers.
220
+ * Supports local, public, S3 (and every S3-compatible provider) and Azure
221
+ * Blob Storage disk drivers.
186
222
  */
187
- export type FilesystemDriver = 'local' | 's3';
223
+ export type FilesystemDriver = 'local' | 's3' | 'azure';
188
224
  export type Visibility = 'public' | 'private';
189
225
  /**
190
226
  * Union type for all disk configurations
191
227
  */
192
- export type DiskConfig = LocalDiskConfig | S3DiskConfig;
228
+ export type DiskConfig = LocalDiskConfig | S3DiskConfig | AzureDiskConfig;
193
229
  /**
194
230
  * A configured disk name (autocompletes to the keys of an augmented
195
231
  * {@link KnownDisks}) or any other string. The `(string & {})` branch
@@ -1 +1 @@
1
- export function localDisk(root,options){return{driver:"local",root,visibility:"private",...options}}export function s3Disk(bucket,options){return{driver:"s3",bucket,region:process.env.AWS_DEFAULT_REGION||"us-east-1",visibility:"private",...options}}export function filebaseDisk(bucket,options){return{driver:"s3",bucket,region:"us-east-1",endpoint:"https://s3.filebase.com",visibility:"private",...options}}export function backblazeDisk(bucket,region,options){return{driver:"s3",bucket,region,endpoint:`https://s3.${region}.backblazeb2.com`,visibility:"private",...options}}export function r2Disk(bucket,accountId,options){return{driver:"s3",bucket,region:"auto",endpoint:`https://${accountId}.r2.cloudflarestorage.com`,visibility:"private",...options}}export function gcsDisk(bucket,options){return{driver:"s3",bucket,region:"auto",endpoint:"https://storage.googleapis.com",visibility:"private",...options}}export function hetznerDisk(bucket,location="fsn1",options){return{driver:"s3",bucket,region:location,endpoint:`https://${location}.your-objectstorage.com`,usePathStyleEndpoint:!0,visibility:"private",...options}}export function configFromEnv(base={}){const env=process.env;return{default:env.FILESYSTEM_DISK||base.default||"local",disks:{...base.disks,...env.AWS_ACCESS_KEY_ID&&env.AWS_BUCKET?{s3:{driver:"s3",bucket:env.AWS_BUCKET,region:env.AWS_DEFAULT_REGION||"us-east-1",endpoint:env.AWS_ENDPOINT,url:env.AWS_URL,usePathStyleEndpoint:env.AWS_USE_PATH_STYLE_ENDPOINT==="true",credentials:{key:env.AWS_ACCESS_KEY_ID,secret:env.AWS_SECRET_ACCESS_KEY||""}}}:{}}}}
1
+ export function localDisk(root,options){return{driver:"local",root,visibility:"private",...options}}export function s3Disk(bucket,options){return{driver:"s3",bucket,region:process.env.AWS_DEFAULT_REGION||"us-east-1",visibility:"private",...options}}export function filebaseDisk(bucket,options){return{driver:"s3",bucket,region:"us-east-1",endpoint:"https://s3.filebase.com",visibility:"private",...options}}export function backblazeDisk(bucket,region,options){return{driver:"s3",bucket,region,endpoint:`https://s3.${region}.backblazeb2.com`,visibility:"private",...options}}export function r2Disk(bucket,accountId,options){return{driver:"s3",bucket,region:"auto",endpoint:`https://${accountId}.r2.cloudflarestorage.com`,visibility:"private",...options}}export function gcsDisk(bucket,options){return{driver:"s3",bucket,region:"auto",endpoint:"https://storage.googleapis.com",visibility:"private",...options}}export function hetznerDisk(bucket,location="fsn1",options){return{driver:"s3",bucket,region:location,endpoint:`https://${location}.your-objectstorage.com`,usePathStyleEndpoint:!0,visibility:"private",...options}}export function azureDisk(container,account,options){return{driver:"azure",account,container,accountKey:process.env.AZURE_STORAGE_ACCOUNT_KEY,visibility:"private",...options}}export function configFromEnv(base={}){const env=process.env;return{default:env.FILESYSTEM_DISK||base.default||"local",disks:{...base.disks,...env.AWS_ACCESS_KEY_ID&&env.AWS_BUCKET?{s3:{driver:"s3",bucket:env.AWS_BUCKET,region:env.AWS_DEFAULT_REGION||"us-east-1",endpoint:env.AWS_ENDPOINT,url:env.AWS_URL,usePathStyleEndpoint:env.AWS_USE_PATH_STYLE_ENDPOINT==="true",credentials:{key:env.AWS_ACCESS_KEY_ID,secret:env.AWS_SECRET_ACCESS_KEY||""}}}:{}}}}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/storage",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.74.33",
5
+ "version": "0.74.35",
6
6
  "description": "The Stacks file system.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -61,19 +61,20 @@
61
61
  "prepublishOnly": "bun run build"
62
62
  },
63
63
  "dependencies": {
64
- "@stacksjs/arrays": "0.74.33",
65
- "@stacksjs/cli": "0.74.33",
66
- "@stacksjs/config": "0.74.33",
67
- "@stacksjs/env": "0.74.33",
68
- "@stacksjs/error-handling": "0.74.33",
69
- "@stacksjs/logging": "0.74.33",
70
- "@stacksjs/path": "0.74.33",
71
- "@stacksjs/strings": "0.74.33",
72
- "@stacksjs/ts-cloud": "^0.12.15",
64
+ "@stacksjs/arrays": "0.74.35",
65
+ "@stacksjs/cli": "0.74.35",
66
+ "@stacksjs/config": "0.74.35",
67
+ "@stacksjs/env": "0.74.35",
68
+ "@stacksjs/error-handling": "0.74.35",
69
+ "@stacksjs/logging": "0.74.35",
70
+ "@stacksjs/path": "0.74.35",
71
+ "@stacksjs/strings": "0.74.35",
72
+ "@stacksjs/ts-cloud": "^0.13.1",
73
+ "@stacksjs/ts-xml": "^0.1.0",
73
74
  "ts-images": "^0.2.11"
74
75
  },
75
76
  "devDependencies": {
76
- "@stacksjs/types": "0.74.33",
77
+ "@stacksjs/types": "0.74.35",
77
78
  "better-dx": "^0.2.24"
78
79
  }
79
80
  }