@iskra-bun/storage-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # @iskra-bun/storage-kit
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f9654df: Initial public release. Transport-agnostic file-storage kit extracted from web-kit: `StorageAdapter`/`BaseStorageAdapter`, local and S3/MinIO adapters, and a `createStorageAdapter` factory. Adds `getStream()` to stream files without buffering them entirely in memory.
8
+
9
+ ### Patch Changes
10
+
11
+ - Fix a path-traversal vulnerability in the local filesystem adapter. `sanitizePath` now strips `..` and `.` segments, and the local adapter resolves every path against the storage root and throws `Path escapes storage root` if the result falls outside it. This is enforced on every `put`/`get`/`getStream`/`delete`/`exists`/`isDirectory` call, so a key like `../secret.txt` can no longer read or delete files outside the configured root.
12
+ - Updated dependencies [f9654df]
13
+ - Updated dependencies
14
+ - Updated dependencies [f9654df]
15
+ - @iskra-bun/core@0.1.1
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @iskra-bun/storage-kit
2
+
3
+ Almacenamiento de archivos de Iskra con adaptadores intercambiables para local, S3 y MinIO.
4
+
5
+ ## Instalacion
6
+
7
+ ```bash
8
+ bun add @iskra-bun/storage-kit @iskra-bun/core
9
+ ```
10
+
11
+ ## Uso rapido
12
+
13
+ ```typescript
14
+ import { createStorageAdapter } from '@iskra-bun/storage-kit';
15
+
16
+ const storage = await createStorageAdapter({ adapter: 'local', basePath: './uploads' });
17
+ await storage.connect();
18
+
19
+ await storage.put('docs/readme.txt', Buffer.from('Hello'));
20
+ const bytes = await storage.get('docs/readme.txt');
21
+ const stream = await storage.getStream('docs/readme.txt');
22
+ ```
23
+
24
+ Cambia `adapter: 's3'` o `adapter: 'minio'` para usar almacenamiento de objetos; la API es identica entre adaptadores.
25
+
26
+ ## Documentacion
27
+
28
+ Guia completa: [docs/storage-kit.md](../../docs/storage-kit.md)
29
+
30
+ ## Licencia
31
+
32
+ AGPL-3.0-or-later
@@ -0,0 +1,245 @@
1
+ // src/base.ts
2
+ var BaseStorageAdapter = class {
3
+ connected = false;
4
+ isConnected() {
5
+ return this.connected;
6
+ }
7
+ ensureConnected() {
8
+ if (!this.connected) {
9
+ throw new Error("Storage not connected. Call connect() first.");
10
+ }
11
+ }
12
+ async copy(from, to) {
13
+ const data = await this.get(from);
14
+ if (!data) {
15
+ throw new Error(`Source file not found: ${from}`);
16
+ }
17
+ await this.put(to, data);
18
+ }
19
+ async move(from, to) {
20
+ await this.copy(from, to);
21
+ await this.delete(from);
22
+ }
23
+ generateFileName(originalName) {
24
+ const ext = originalName.split(".").pop();
25
+ const timestamp = Date.now();
26
+ const random = crypto.randomUUID().replace(/-/g, "");
27
+ return `${timestamp}-${random}.${ext}`;
28
+ }
29
+ sanitizePath(path) {
30
+ const normalized = path.replace(/\\/g, "/");
31
+ const segments = normalized.split("/").filter((segment) => segment !== "" && !/^\.+$/.test(segment));
32
+ return segments.join("/");
33
+ }
34
+ getMimeType(filename) {
35
+ const ext = filename.split(".").pop()?.toLowerCase();
36
+ const mimeTypes = {
37
+ jpg: "image/jpeg",
38
+ jpeg: "image/jpeg",
39
+ png: "image/png",
40
+ gif: "image/gif",
41
+ pdf: "application/pdf",
42
+ txt: "text/plain",
43
+ json: "application/json",
44
+ zip: "application/zip"
45
+ };
46
+ return mimeTypes[ext || ""] || "application/octet-stream";
47
+ }
48
+ };
49
+
50
+ // src/adapters/s3.ts
51
+ import {
52
+ S3Client,
53
+ PutObjectCommand,
54
+ GetObjectCommand,
55
+ DeleteObjectCommand,
56
+ HeadObjectCommand,
57
+ HeadBucketCommand,
58
+ ListObjectsV2Command,
59
+ CopyObjectCommand
60
+ } from "@aws-sdk/client-s3";
61
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
62
+ var S3StorageAdapter = class extends BaseStorageAdapter {
63
+ constructor(config) {
64
+ super();
65
+ this.config = config;
66
+ const conn = config.connection || {};
67
+ if (conn.endpoint?.startsWith("http://") && conn.useSSL !== false) {
68
+ throw new Error(
69
+ "Refusing plaintext S3 endpoint; set useSSL:false to override"
70
+ );
71
+ }
72
+ this.bucket = conn.bucket || "iskra-storage";
73
+ this.client = new S3Client({
74
+ endpoint: conn.endpoint,
75
+ region: conn.region || "us-east-1",
76
+ credentials: conn.accessKey && conn.secretKey ? { accessKeyId: conn.accessKey, secretAccessKey: conn.secretKey } : void 0,
77
+ forcePathStyle: !!conn.endpoint
78
+ });
79
+ }
80
+ config;
81
+ client;
82
+ bucket;
83
+ async connect() {
84
+ try {
85
+ await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
86
+ this.connected = true;
87
+ } catch (err) {
88
+ throw new Error(`Failed to connect to S3 bucket "${this.bucket}": ${err.message}`);
89
+ }
90
+ }
91
+ async disconnect() {
92
+ this.client.destroy();
93
+ this.connected = false;
94
+ }
95
+ async put(path, data, options) {
96
+ this.ensureConnected();
97
+ const key = this.sanitizePath(path);
98
+ let body;
99
+ if (data instanceof ReadableStream) {
100
+ const response = new Response(data);
101
+ body = new Uint8Array(await response.arrayBuffer());
102
+ } else {
103
+ body = data;
104
+ }
105
+ await this.client.send(
106
+ new PutObjectCommand({
107
+ Bucket: this.bucket,
108
+ Key: key,
109
+ Body: body,
110
+ ContentType: options?.contentType || this.getMimeType(key),
111
+ Metadata: options?.metadata
112
+ })
113
+ );
114
+ return {
115
+ name: key.split("/").pop() || key,
116
+ path: key,
117
+ size: body.length,
118
+ mimeType: options?.contentType || this.getMimeType(key),
119
+ lastModified: /* @__PURE__ */ new Date()
120
+ };
121
+ }
122
+ async get(path) {
123
+ this.ensureConnected();
124
+ const key = this.sanitizePath(path);
125
+ try {
126
+ const response = await this.client.send(
127
+ new GetObjectCommand({ Bucket: this.bucket, Key: key })
128
+ );
129
+ if (!response.Body) return null;
130
+ return new Uint8Array(await response.Body.transformToByteArray());
131
+ } catch (err) {
132
+ if (err.name === "NoSuchKey" || err.$metadata?.httpStatusCode === 404) {
133
+ return null;
134
+ }
135
+ throw err;
136
+ }
137
+ }
138
+ async getStream(path) {
139
+ this.ensureConnected();
140
+ const key = this.sanitizePath(path);
141
+ try {
142
+ const response = await this.client.send(
143
+ new GetObjectCommand({ Bucket: this.bucket, Key: key })
144
+ );
145
+ if (!response.Body) return null;
146
+ if (typeof response.Body.transformToWebStream === "function") {
147
+ return response.Body.transformToWebStream();
148
+ }
149
+ const bytes = await response.Body.transformToByteArray();
150
+ return new ReadableStream({
151
+ start(controller) {
152
+ controller.enqueue(bytes);
153
+ controller.close();
154
+ }
155
+ });
156
+ } catch (err) {
157
+ if (err.name === "NoSuchKey" || err.$metadata?.httpStatusCode === 404) {
158
+ return null;
159
+ }
160
+ throw err;
161
+ }
162
+ }
163
+ async delete(path) {
164
+ this.ensureConnected();
165
+ const key = this.sanitizePath(path);
166
+ await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
167
+ }
168
+ async exists(path) {
169
+ this.ensureConnected();
170
+ const key = this.sanitizePath(path);
171
+ try {
172
+ await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
173
+ return true;
174
+ } catch (err) {
175
+ if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
176
+ return false;
177
+ }
178
+ throw err;
179
+ }
180
+ }
181
+ async list(prefix) {
182
+ this.ensureConnected();
183
+ const files = [];
184
+ let continuationToken;
185
+ do {
186
+ const response = await this.client.send(
187
+ new ListObjectsV2Command({
188
+ Bucket: this.bucket,
189
+ Prefix: prefix ? this.sanitizePath(prefix) : void 0,
190
+ ContinuationToken: continuationToken
191
+ })
192
+ );
193
+ if (response.Contents) {
194
+ for (const obj of response.Contents) {
195
+ if (!obj.Key) continue;
196
+ files.push({
197
+ name: obj.Key.split("/").pop() || obj.Key,
198
+ path: obj.Key,
199
+ size: obj.Size || 0,
200
+ mimeType: this.getMimeType(obj.Key),
201
+ lastModified: obj.LastModified
202
+ });
203
+ }
204
+ }
205
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
206
+ } while (continuationToken);
207
+ return files;
208
+ }
209
+ async url(path, expiresIn = 3600) {
210
+ this.ensureConnected();
211
+ const key = this.sanitizePath(path);
212
+ const command = new GetObjectCommand({ Bucket: this.bucket, Key: key });
213
+ return await getSignedUrl(this.client, command, { expiresIn });
214
+ }
215
+ async copy(from, to) {
216
+ this.ensureConnected();
217
+ const sourceKey = this.sanitizePath(from);
218
+ const destKey = this.sanitizePath(to);
219
+ await this.client.send(
220
+ new CopyObjectCommand({
221
+ Bucket: this.bucket,
222
+ CopySource: `${this.bucket}/${sourceKey}`,
223
+ Key: destKey
224
+ })
225
+ );
226
+ }
227
+ async isDirectory(path) {
228
+ this.ensureConnected();
229
+ const prefix = this.sanitizePath(path).replace(/\/?$/, "/");
230
+ const response = await this.client.send(
231
+ new ListObjectsV2Command({
232
+ Bucket: this.bucket,
233
+ Prefix: prefix,
234
+ MaxKeys: 1
235
+ })
236
+ );
237
+ return (response.Contents?.length || 0) > 0;
238
+ }
239
+ };
240
+
241
+ export {
242
+ BaseStorageAdapter,
243
+ S3StorageAdapter
244
+ };
245
+ //# sourceMappingURL=chunk-TNFLKGYU.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/base.ts","../src/adapters/s3.ts"],"sourcesContent":["export interface StorageConfig {\n adapter: \"local\" | \"minio\" | \"s3\";\n basePath?: string;\n connection?: {\n endpoint?: string;\n accessKey?: string;\n secretKey?: string;\n bucket?: string;\n region?: string;\n useSSL?: boolean;\n };\n}\n\nexport interface StorageFile {\n name: string;\n path: string;\n size: number;\n mimeType?: string;\n lastModified?: Date;\n url?: string;\n}\n\nexport interface PutOptions {\n contentType?: string;\n metadata?: Record<string, string>;\n public?: boolean;\n}\n\nexport interface StorageAdapter {\n connect(): Promise<void>;\n disconnect(): Promise<void>;\n put(path: string, data: Uint8Array | Buffer | ReadableStream, options?: PutOptions): Promise<StorageFile>;\n get(path: string): Promise<Uint8Array | null>;\n getStream(path: string): Promise<ReadableStream | null>;\n delete(path: string): Promise<void>;\n exists(path: string): Promise<boolean>;\n list(prefix?: string): Promise<StorageFile[]>;\n url(path: string, expiresIn?: number): Promise<string>;\n copy(from: string, to: string): Promise<void>;\n move(from: string, to: string): Promise<void>;\n isDirectory(path: string): Promise<boolean>;\n}\n\nexport abstract class BaseStorageAdapter implements StorageAdapter {\n protected connected = false;\n\n abstract connect(): Promise<void>;\n abstract disconnect(): Promise<void>;\n abstract put(path: string, data: Uint8Array | Buffer | ReadableStream, options?: PutOptions): Promise<StorageFile>;\n abstract get(path: string): Promise<Uint8Array | null>;\n abstract getStream(path: string): Promise<ReadableStream | null>;\n abstract delete(path: string): Promise<void>;\n abstract exists(path: string): Promise<boolean>;\n abstract list(prefix?: string): Promise<StorageFile[]>;\n abstract url(path: string, expiresIn?: number): Promise<string>;\n abstract isDirectory(path: string): Promise<boolean>;\n\n isConnected(): boolean {\n return this.connected;\n }\n\n protected ensureConnected(): void {\n if (!this.connected) {\n throw new Error(\"Storage not connected. Call connect() first.\");\n }\n }\n\n async copy(from: string, to: string): Promise<void> {\n const data = await this.get(from);\n if (!data) {\n throw new Error(`Source file not found: ${from}`);\n }\n await this.put(to, data);\n }\n\n async move(from: string, to: string): Promise<void> {\n await this.copy(from, to);\n await this.delete(from);\n }\n\n protected generateFileName(originalName: string): string {\n const ext = originalName.split(\".\").pop();\n const timestamp = Date.now();\n const random = crypto.randomUUID().replace(/-/g, \"\");\n return `${timestamp}-${random}.${ext}`;\n }\n\n protected sanitizePath(path: string): string {\n const normalized = path.replace(/\\\\/g, \"/\");\n const segments = normalized\n .split(\"/\")\n .filter((segment) => segment !== \"\" && !/^\\.+$/.test(segment));\n return segments.join(\"/\");\n }\n\n protected getMimeType(filename: string): string {\n const ext = filename.split(\".\").pop()?.toLowerCase();\n const mimeTypes: Record<string, string> = {\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n gif: \"image/gif\",\n pdf: \"application/pdf\",\n txt: \"text/plain\",\n json: \"application/json\",\n zip: \"application/zip\",\n };\n return mimeTypes[ext || \"\"] || \"application/octet-stream\";\n }\n}\n","import { BaseStorageAdapter } from \"../base\";\nimport type { StorageConfig, StorageFile, PutOptions } from \"../base\";\nimport {\n S3Client,\n PutObjectCommand,\n GetObjectCommand,\n DeleteObjectCommand,\n HeadObjectCommand,\n HeadBucketCommand,\n ListObjectsV2Command,\n CopyObjectCommand,\n} from \"@aws-sdk/client-s3\";\nimport { getSignedUrl } from \"@aws-sdk/s3-request-presigner\";\n\nexport class S3StorageAdapter extends BaseStorageAdapter {\n private client: S3Client;\n private bucket: string;\n\n constructor(private config: StorageConfig) {\n super();\n const conn = config.connection || {};\n\n if (conn.endpoint?.startsWith(\"http://\") && conn.useSSL !== false) {\n throw new Error(\n \"Refusing plaintext S3 endpoint; set useSSL:false to override\"\n );\n }\n\n this.bucket = conn.bucket || \"iskra-storage\";\n\n this.client = new S3Client({\n endpoint: conn.endpoint,\n region: conn.region || \"us-east-1\",\n credentials:\n conn.accessKey && conn.secretKey\n ? { accessKeyId: conn.accessKey, secretAccessKey: conn.secretKey }\n : undefined,\n forcePathStyle: !!conn.endpoint,\n });\n }\n\n async connect(): Promise<void> {\n try {\n await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));\n this.connected = true;\n } catch (err: any) {\n throw new Error(`Failed to connect to S3 bucket \"${this.bucket}\": ${err.message}`);\n }\n }\n\n async disconnect(): Promise<void> {\n this.client.destroy();\n this.connected = false;\n }\n\n async put(\n path: string,\n data: Uint8Array | Buffer | ReadableStream,\n options?: PutOptions\n ): Promise<StorageFile> {\n this.ensureConnected();\n const key = this.sanitizePath(path);\n\n let body: Uint8Array | Buffer;\n if (data instanceof ReadableStream) {\n const response = new Response(data);\n body = new Uint8Array(await response.arrayBuffer());\n } else {\n body = data;\n }\n\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: body,\n ContentType: options?.contentType || this.getMimeType(key),\n Metadata: options?.metadata,\n })\n );\n\n return {\n name: key.split(\"/\").pop() || key,\n path: key,\n size: body.length,\n mimeType: options?.contentType || this.getMimeType(key),\n lastModified: new Date(),\n };\n }\n\n async get(path: string): Promise<Uint8Array | null> {\n this.ensureConnected();\n const key = this.sanitizePath(path);\n\n try {\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.bucket, Key: key })\n );\n\n if (!response.Body) return null;\n return new Uint8Array(await response.Body.transformToByteArray());\n } catch (err: any) {\n if (err.name === \"NoSuchKey\" || err.$metadata?.httpStatusCode === 404) {\n return null;\n }\n throw err;\n }\n }\n\n async getStream(path: string): Promise<ReadableStream | null> {\n this.ensureConnected();\n const key = this.sanitizePath(path);\n\n try {\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.bucket, Key: key })\n );\n\n if (!response.Body) return null;\n\n if (typeof (response.Body as any).transformToWebStream === \"function\") {\n return (response.Body as any).transformToWebStream();\n }\n\n // Fallback: buffer then wrap in a ReadableStream\n const bytes = await response.Body.transformToByteArray();\n return new ReadableStream({\n start(controller) {\n controller.enqueue(bytes);\n controller.close();\n },\n });\n } catch (err: any) {\n if (err.name === \"NoSuchKey\" || err.$metadata?.httpStatusCode === 404) {\n return null;\n }\n throw err;\n }\n }\n\n async delete(path: string): Promise<void> {\n this.ensureConnected();\n const key = this.sanitizePath(path);\n\n await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));\n }\n\n async exists(path: string): Promise<boolean> {\n this.ensureConnected();\n const key = this.sanitizePath(path);\n\n try {\n await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));\n return true;\n } catch (err: any) {\n if (err.name === \"NotFound\" || err.$metadata?.httpStatusCode === 404) {\n return false;\n }\n throw err;\n }\n }\n\n async list(prefix?: string): Promise<StorageFile[]> {\n this.ensureConnected();\n const files: StorageFile[] = [];\n let continuationToken: string | undefined;\n\n do {\n const response = await this.client.send(\n new ListObjectsV2Command({\n Bucket: this.bucket,\n Prefix: prefix ? this.sanitizePath(prefix) : undefined,\n ContinuationToken: continuationToken,\n })\n );\n\n if (response.Contents) {\n for (const obj of response.Contents) {\n if (!obj.Key) continue;\n files.push({\n name: obj.Key.split(\"/\").pop() || obj.Key,\n path: obj.Key,\n size: obj.Size || 0,\n mimeType: this.getMimeType(obj.Key),\n lastModified: obj.LastModified,\n });\n }\n }\n\n continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;\n } while (continuationToken);\n\n return files;\n }\n\n async url(path: string, expiresIn: number = 3600): Promise<string> {\n this.ensureConnected();\n const key = this.sanitizePath(path);\n\n const command = new GetObjectCommand({ Bucket: this.bucket, Key: key });\n return await getSignedUrl(this.client, command, { expiresIn });\n }\n\n async copy(from: string, to: string): Promise<void> {\n this.ensureConnected();\n const sourceKey = this.sanitizePath(from);\n const destKey = this.sanitizePath(to);\n\n await this.client.send(\n new CopyObjectCommand({\n Bucket: this.bucket,\n CopySource: `${this.bucket}/${sourceKey}`,\n Key: destKey,\n })\n );\n }\n\n async isDirectory(path: string): Promise<boolean> {\n this.ensureConnected();\n const prefix = this.sanitizePath(path).replace(/\\/?$/, \"/\");\n\n const response = await this.client.send(\n new ListObjectsV2Command({\n Bucket: this.bucket,\n Prefix: prefix,\n MaxKeys: 1,\n })\n );\n\n return (response.Contents?.length || 0) > 0;\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,102 @@
1
+ interface StorageConfig {
2
+ adapter: "local" | "minio" | "s3";
3
+ basePath?: string;
4
+ connection?: {
5
+ endpoint?: string;
6
+ accessKey?: string;
7
+ secretKey?: string;
8
+ bucket?: string;
9
+ region?: string;
10
+ useSSL?: boolean;
11
+ };
12
+ }
13
+ interface StorageFile {
14
+ name: string;
15
+ path: string;
16
+ size: number;
17
+ mimeType?: string;
18
+ lastModified?: Date;
19
+ url?: string;
20
+ }
21
+ interface PutOptions {
22
+ contentType?: string;
23
+ metadata?: Record<string, string>;
24
+ public?: boolean;
25
+ }
26
+ interface StorageAdapter {
27
+ connect(): Promise<void>;
28
+ disconnect(): Promise<void>;
29
+ put(path: string, data: Uint8Array | Buffer | ReadableStream, options?: PutOptions): Promise<StorageFile>;
30
+ get(path: string): Promise<Uint8Array | null>;
31
+ getStream(path: string): Promise<ReadableStream | null>;
32
+ delete(path: string): Promise<void>;
33
+ exists(path: string): Promise<boolean>;
34
+ list(prefix?: string): Promise<StorageFile[]>;
35
+ url(path: string, expiresIn?: number): Promise<string>;
36
+ copy(from: string, to: string): Promise<void>;
37
+ move(from: string, to: string): Promise<void>;
38
+ isDirectory(path: string): Promise<boolean>;
39
+ }
40
+ declare abstract class BaseStorageAdapter implements StorageAdapter {
41
+ protected connected: boolean;
42
+ abstract connect(): Promise<void>;
43
+ abstract disconnect(): Promise<void>;
44
+ abstract put(path: string, data: Uint8Array | Buffer | ReadableStream, options?: PutOptions): Promise<StorageFile>;
45
+ abstract get(path: string): Promise<Uint8Array | null>;
46
+ abstract getStream(path: string): Promise<ReadableStream | null>;
47
+ abstract delete(path: string): Promise<void>;
48
+ abstract exists(path: string): Promise<boolean>;
49
+ abstract list(prefix?: string): Promise<StorageFile[]>;
50
+ abstract url(path: string, expiresIn?: number): Promise<string>;
51
+ abstract isDirectory(path: string): Promise<boolean>;
52
+ isConnected(): boolean;
53
+ protected ensureConnected(): void;
54
+ copy(from: string, to: string): Promise<void>;
55
+ move(from: string, to: string): Promise<void>;
56
+ protected generateFileName(originalName: string): string;
57
+ protected sanitizePath(path: string): string;
58
+ protected getMimeType(filename: string): string;
59
+ }
60
+
61
+ declare class LocalStorageAdapter extends BaseStorageAdapter {
62
+ private basePath;
63
+ constructor(config: StorageConfig);
64
+ connect(): Promise<void>;
65
+ disconnect(): Promise<void>;
66
+ private resolveWithinBase;
67
+ put(filePath: string, data: Uint8Array | Buffer, options?: PutOptions): Promise<StorageFile>;
68
+ get(filePath: string): Promise<Uint8Array | null>;
69
+ getStream(filePath: string): Promise<ReadableStream | null>;
70
+ delete(filePath: string): Promise<void>;
71
+ exists(filePath: string): Promise<boolean>;
72
+ isDirectory(filePath: string): Promise<boolean>;
73
+ list(prefix?: string): Promise<StorageFile[]>;
74
+ url(filePath: string, _expiresIn?: number): Promise<string>;
75
+ }
76
+
77
+ declare class S3StorageAdapter extends BaseStorageAdapter {
78
+ private config;
79
+ private client;
80
+ private bucket;
81
+ constructor(config: StorageConfig);
82
+ connect(): Promise<void>;
83
+ disconnect(): Promise<void>;
84
+ put(path: string, data: Uint8Array | Buffer | ReadableStream, options?: PutOptions): Promise<StorageFile>;
85
+ get(path: string): Promise<Uint8Array | null>;
86
+ getStream(path: string): Promise<ReadableStream | null>;
87
+ delete(path: string): Promise<void>;
88
+ exists(path: string): Promise<boolean>;
89
+ list(prefix?: string): Promise<StorageFile[]>;
90
+ url(path: string, expiresIn?: number): Promise<string>;
91
+ copy(from: string, to: string): Promise<void>;
92
+ isDirectory(path: string): Promise<boolean>;
93
+ }
94
+
95
+ /**
96
+ * Crea e inicializa el adaptador de almacenamiento correcto segun la configuracion.
97
+ * El adaptador de S3/MinIO se importa de forma lazy para evitar cargar el SDK de AWS
98
+ * en entornos que solo usan almacenamiento local.
99
+ */
100
+ declare function createStorageAdapter(config: StorageConfig): Promise<BaseStorageAdapter>;
101
+
102
+ export { BaseStorageAdapter, LocalStorageAdapter, type PutOptions, S3StorageAdapter, type StorageAdapter, type StorageConfig, type StorageFile, createStorageAdapter };
package/dist/index.js ADDED
@@ -0,0 +1,162 @@
1
+ import {
2
+ BaseStorageAdapter,
3
+ S3StorageAdapter
4
+ } from "./chunk-TNFLKGYU.js";
5
+
6
+ // src/adapters/local.ts
7
+ import path from "path";
8
+ import fs from "fs/promises";
9
+ var LocalStorageAdapter = class extends BaseStorageAdapter {
10
+ basePath;
11
+ constructor(config) {
12
+ super();
13
+ this.basePath = config.basePath || "./storage";
14
+ }
15
+ async connect() {
16
+ await fs.mkdir(this.basePath, { recursive: true });
17
+ this.connected = true;
18
+ }
19
+ async disconnect() {
20
+ this.connected = false;
21
+ }
22
+ resolveWithinBase(filePath) {
23
+ const sanitizedPath = this.sanitizePath(filePath);
24
+ const base = path.resolve(this.basePath);
25
+ const resolved = path.resolve(base, sanitizedPath);
26
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) {
27
+ throw new Error(`Path escapes storage root: ${filePath}`);
28
+ }
29
+ return resolved;
30
+ }
31
+ async put(filePath, data, options) {
32
+ this.ensureConnected();
33
+ const sanitizedPath = this.sanitizePath(filePath);
34
+ const fullPath = this.resolveWithinBase(filePath);
35
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
36
+ await fs.writeFile(fullPath, data);
37
+ const stat = await fs.stat(fullPath);
38
+ return {
39
+ name: path.basename(sanitizedPath),
40
+ path: sanitizedPath,
41
+ size: stat.size,
42
+ mimeType: options?.contentType || this.getMimeType(sanitizedPath),
43
+ lastModified: stat.mtime,
44
+ url: await this.url(sanitizedPath)
45
+ };
46
+ }
47
+ async get(filePath) {
48
+ this.ensureConnected();
49
+ const fullPath = this.resolveWithinBase(filePath);
50
+ try {
51
+ return await fs.readFile(fullPath);
52
+ } catch (error) {
53
+ if (error.code === "ENOENT") return null;
54
+ throw error;
55
+ }
56
+ }
57
+ async getStream(filePath) {
58
+ this.ensureConnected();
59
+ const fullPath = this.resolveWithinBase(filePath);
60
+ try {
61
+ await fs.access(fullPath);
62
+ } catch {
63
+ return null;
64
+ }
65
+ return Bun.file(fullPath).stream();
66
+ }
67
+ async delete(filePath) {
68
+ this.ensureConnected();
69
+ const fullPath = this.resolveWithinBase(filePath);
70
+ try {
71
+ await fs.unlink(fullPath);
72
+ } catch (error) {
73
+ if (error.code !== "ENOENT") throw error;
74
+ }
75
+ }
76
+ async exists(filePath) {
77
+ this.ensureConnected();
78
+ let fullPath;
79
+ try {
80
+ fullPath = this.resolveWithinBase(filePath);
81
+ } catch {
82
+ return false;
83
+ }
84
+ try {
85
+ await fs.access(fullPath);
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+ async isDirectory(filePath) {
92
+ this.ensureConnected();
93
+ let fullPath;
94
+ try {
95
+ fullPath = this.resolveWithinBase(filePath);
96
+ } catch {
97
+ return false;
98
+ }
99
+ try {
100
+ const stat = await fs.stat(fullPath);
101
+ return stat.isDirectory();
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+ async list(prefix) {
107
+ this.ensureConnected();
108
+ const searchPath = prefix ? path.join(this.basePath, this.sanitizePath(prefix)) : this.basePath;
109
+ const files = [];
110
+ const walk = async (dir) => {
111
+ try {
112
+ const entries = await fs.readdir(dir, { withFileTypes: true });
113
+ for (const entry of entries) {
114
+ const fullPath = path.join(dir, entry.name);
115
+ if (entry.isDirectory()) {
116
+ await walk(fullPath);
117
+ } else {
118
+ const relativePath = path.relative(this.basePath, fullPath);
119
+ const stat = await fs.stat(fullPath);
120
+ files.push({
121
+ name: entry.name,
122
+ path: this.sanitizePath(relativePath),
123
+ size: stat.size,
124
+ mimeType: this.getMimeType(entry.name),
125
+ lastModified: stat.mtime,
126
+ url: await this.url(this.sanitizePath(relativePath))
127
+ });
128
+ }
129
+ }
130
+ } catch (e) {
131
+ if (e.code !== "ENOENT") throw e;
132
+ }
133
+ };
134
+ await walk(searchPath);
135
+ return files;
136
+ }
137
+ async url(filePath, _expiresIn) {
138
+ const sanitizedPath = this.sanitizePath(filePath);
139
+ return `/storage/${sanitizedPath}`;
140
+ }
141
+ };
142
+
143
+ // src/factory.ts
144
+ async function createStorageAdapter(config) {
145
+ if (config.adapter === "local") {
146
+ return new LocalStorageAdapter(config);
147
+ }
148
+ if (config.adapter === "s3" || config.adapter === "minio") {
149
+ const { S3StorageAdapter: S3StorageAdapter2 } = await import("./s3-A3GLE5AG.js");
150
+ return new S3StorageAdapter2(config);
151
+ }
152
+ throw new Error(
153
+ `Adaptador de almacenamiento no soportado: "${config.adapter}". Usa "local", "s3" o "minio".`
154
+ );
155
+ }
156
+ export {
157
+ BaseStorageAdapter,
158
+ LocalStorageAdapter,
159
+ S3StorageAdapter,
160
+ createStorageAdapter
161
+ };
162
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/adapters/local.ts","../src/factory.ts"],"sourcesContent":["import { BaseStorageAdapter, type PutOptions, type StorageConfig, type StorageFile } from \"../base\";\nimport path from \"node:path\";\nimport fs from \"node:fs/promises\";\n\nexport class LocalStorageAdapter extends BaseStorageAdapter {\n private basePath: string;\n\n constructor(config: StorageConfig) {\n super();\n this.basePath = config.basePath || \"./storage\";\n }\n\n async connect(): Promise<void> {\n await fs.mkdir(this.basePath, { recursive: true });\n this.connected = true;\n }\n\n async disconnect(): Promise<void> {\n this.connected = false;\n }\n\n private resolveWithinBase(filePath: string): string {\n const sanitizedPath = this.sanitizePath(filePath);\n const base = path.resolve(this.basePath);\n const resolved = path.resolve(base, sanitizedPath);\n if (resolved !== base && !resolved.startsWith(base + path.sep)) {\n throw new Error(`Path escapes storage root: ${filePath}`);\n }\n return resolved;\n }\n\n async put(filePath: string, data: Uint8Array | Buffer, options?: PutOptions): Promise<StorageFile> {\n this.ensureConnected();\n\n const sanitizedPath = this.sanitizePath(filePath);\n const fullPath = this.resolveWithinBase(filePath);\n\n await fs.mkdir(path.dirname(fullPath), { recursive: true });\n await fs.writeFile(fullPath, data);\n\n const stat = await fs.stat(fullPath);\n\n return {\n name: path.basename(sanitizedPath),\n path: sanitizedPath,\n size: stat.size,\n mimeType: options?.contentType || this.getMimeType(sanitizedPath),\n lastModified: stat.mtime,\n url: await this.url(sanitizedPath),\n };\n }\n\n async get(filePath: string): Promise<Uint8Array | null> {\n this.ensureConnected();\n const fullPath = this.resolveWithinBase(filePath);\n\n try {\n return await fs.readFile(fullPath);\n } catch (error: any) {\n if (error.code === \"ENOENT\") return null;\n throw error;\n }\n }\n\n async getStream(filePath: string): Promise<ReadableStream | null> {\n this.ensureConnected();\n const fullPath = this.resolveWithinBase(filePath);\n\n try {\n await fs.access(fullPath);\n } catch {\n return null;\n }\n\n return Bun.file(fullPath).stream();\n }\n\n async delete(filePath: string): Promise<void> {\n this.ensureConnected();\n const fullPath = this.resolveWithinBase(filePath);\n\n try {\n await fs.unlink(fullPath);\n } catch (error: any) {\n if (error.code !== \"ENOENT\") throw error;\n }\n }\n\n async exists(filePath: string): Promise<boolean> {\n this.ensureConnected();\n let fullPath: string;\n try {\n fullPath = this.resolveWithinBase(filePath);\n } catch {\n return false;\n }\n try {\n await fs.access(fullPath);\n return true;\n } catch {\n return false;\n }\n }\n\n async isDirectory(filePath: string): Promise<boolean> {\n this.ensureConnected();\n let fullPath: string;\n try {\n fullPath = this.resolveWithinBase(filePath);\n } catch {\n return false;\n }\n try {\n const stat = await fs.stat(fullPath);\n return stat.isDirectory();\n } catch {\n return false;\n }\n }\n\n async list(prefix?: string): Promise<StorageFile[]> {\n this.ensureConnected();\n const searchPath = prefix\n ? path.join(this.basePath, this.sanitizePath(prefix))\n : this.basePath;\n const files: StorageFile[] = [];\n\n const walk = async (dir: string): Promise<void> => {\n try {\n const entries = await fs.readdir(dir, { withFileTypes: true });\n for (const entry of entries) {\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n await walk(fullPath);\n } else {\n const relativePath = path.relative(this.basePath, fullPath);\n const stat = await fs.stat(fullPath);\n files.push({\n name: entry.name,\n path: this.sanitizePath(relativePath),\n size: stat.size,\n mimeType: this.getMimeType(entry.name),\n lastModified: stat.mtime,\n url: await this.url(this.sanitizePath(relativePath)),\n });\n }\n }\n } catch (e: any) {\n if (e.code !== \"ENOENT\") throw e;\n }\n };\n\n await walk(searchPath);\n return files;\n }\n\n async url(filePath: string, _expiresIn?: number): Promise<string> {\n const sanitizedPath = this.sanitizePath(filePath);\n return `/storage/${sanitizedPath}`;\n }\n}\n","import type { StorageConfig } from \"./base\";\nimport { BaseStorageAdapter } from \"./base\";\nimport { LocalStorageAdapter } from \"./adapters/local\";\n\n/**\n * Crea e inicializa el adaptador de almacenamiento correcto segun la configuracion.\n * El adaptador de S3/MinIO se importa de forma lazy para evitar cargar el SDK de AWS\n * en entornos que solo usan almacenamiento local.\n */\nexport async function createStorageAdapter(\n config: StorageConfig\n): Promise<BaseStorageAdapter> {\n if (config.adapter === \"local\") {\n return new LocalStorageAdapter(config);\n }\n\n if (config.adapter === \"s3\" || config.adapter === \"minio\") {\n const { S3StorageAdapter } = await import(\"./adapters/s3\");\n return new S3StorageAdapter(config);\n }\n\n throw new Error(\n `Adaptador de almacenamiento no soportado: \"${config.adapter}\". Usa \"local\", \"s3\" o \"minio\".`\n );\n}\n"],"mappings":"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","names":["S3StorageAdapter"]}
@@ -0,0 +1,7 @@
1
+ import {
2
+ S3StorageAdapter
3
+ } from "./chunk-TNFLKGYU.js";
4
+ export {
5
+ S3StorageAdapter
6
+ };
7
+ //# sourceMappingURL=s3-A3GLE5AG.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@iskra-bun/storage-kit",
3
+ "version": "0.1.0",
4
+ "description": "Almacenamiento de archivos de Iskra con adaptadores para local, S3 y MinIO.",
5
+ "keywords": [
6
+ "iskra",
7
+ "bun",
8
+ "storage",
9
+ "s3",
10
+ "minio",
11
+ "files"
12
+ ],
13
+ "author": "Joan Lascano",
14
+ "license": "AGPL-3.0-or-later",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/fearful/iskra.git",
18
+ "directory": "packages/storage-kit"
19
+ },
20
+ "homepage": "https://github.com/fearful/iskra/tree/main/packages/storage-kit#readme",
21
+ "bugs": "https://github.com/fearful/iskra/issues",
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "module": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "source": "./src/index.ts",
29
+ "bun": "./src/index.ts",
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js",
32
+ "default": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src",
38
+ "README.md",
39
+ "CHANGELOG.md"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "test": "bun test",
46
+ "build": "tsup --config ../../tsup.config.ts"
47
+ },
48
+ "dependencies": {
49
+ "@iskra-bun/core": "0.1.1",
50
+ "@aws-sdk/client-s3": "^3.600.0",
51
+ "@aws-sdk/s3-request-presigner": "^3.600.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/bun": "^1.3.5",
55
+ "@types/node": "^22.10.2"
56
+ }
57
+ }
@@ -0,0 +1,161 @@
1
+ import { BaseStorageAdapter, type PutOptions, type StorageConfig, type StorageFile } from "../base";
2
+ import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+
5
+ export class LocalStorageAdapter extends BaseStorageAdapter {
6
+ private basePath: string;
7
+
8
+ constructor(config: StorageConfig) {
9
+ super();
10
+ this.basePath = config.basePath || "./storage";
11
+ }
12
+
13
+ async connect(): Promise<void> {
14
+ await fs.mkdir(this.basePath, { recursive: true });
15
+ this.connected = true;
16
+ }
17
+
18
+ async disconnect(): Promise<void> {
19
+ this.connected = false;
20
+ }
21
+
22
+ private resolveWithinBase(filePath: string): string {
23
+ const sanitizedPath = this.sanitizePath(filePath);
24
+ const base = path.resolve(this.basePath);
25
+ const resolved = path.resolve(base, sanitizedPath);
26
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) {
27
+ throw new Error(`Path escapes storage root: ${filePath}`);
28
+ }
29
+ return resolved;
30
+ }
31
+
32
+ async put(filePath: string, data: Uint8Array | Buffer, options?: PutOptions): Promise<StorageFile> {
33
+ this.ensureConnected();
34
+
35
+ const sanitizedPath = this.sanitizePath(filePath);
36
+ const fullPath = this.resolveWithinBase(filePath);
37
+
38
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
39
+ await fs.writeFile(fullPath, data);
40
+
41
+ const stat = await fs.stat(fullPath);
42
+
43
+ return {
44
+ name: path.basename(sanitizedPath),
45
+ path: sanitizedPath,
46
+ size: stat.size,
47
+ mimeType: options?.contentType || this.getMimeType(sanitizedPath),
48
+ lastModified: stat.mtime,
49
+ url: await this.url(sanitizedPath),
50
+ };
51
+ }
52
+
53
+ async get(filePath: string): Promise<Uint8Array | null> {
54
+ this.ensureConnected();
55
+ const fullPath = this.resolveWithinBase(filePath);
56
+
57
+ try {
58
+ return await fs.readFile(fullPath);
59
+ } catch (error: any) {
60
+ if (error.code === "ENOENT") return null;
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ async getStream(filePath: string): Promise<ReadableStream | null> {
66
+ this.ensureConnected();
67
+ const fullPath = this.resolveWithinBase(filePath);
68
+
69
+ try {
70
+ await fs.access(fullPath);
71
+ } catch {
72
+ return null;
73
+ }
74
+
75
+ return Bun.file(fullPath).stream();
76
+ }
77
+
78
+ async delete(filePath: string): Promise<void> {
79
+ this.ensureConnected();
80
+ const fullPath = this.resolveWithinBase(filePath);
81
+
82
+ try {
83
+ await fs.unlink(fullPath);
84
+ } catch (error: any) {
85
+ if (error.code !== "ENOENT") throw error;
86
+ }
87
+ }
88
+
89
+ async exists(filePath: string): Promise<boolean> {
90
+ this.ensureConnected();
91
+ let fullPath: string;
92
+ try {
93
+ fullPath = this.resolveWithinBase(filePath);
94
+ } catch {
95
+ return false;
96
+ }
97
+ try {
98
+ await fs.access(fullPath);
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ async isDirectory(filePath: string): Promise<boolean> {
106
+ this.ensureConnected();
107
+ let fullPath: string;
108
+ try {
109
+ fullPath = this.resolveWithinBase(filePath);
110
+ } catch {
111
+ return false;
112
+ }
113
+ try {
114
+ const stat = await fs.stat(fullPath);
115
+ return stat.isDirectory();
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ async list(prefix?: string): Promise<StorageFile[]> {
122
+ this.ensureConnected();
123
+ const searchPath = prefix
124
+ ? path.join(this.basePath, this.sanitizePath(prefix))
125
+ : this.basePath;
126
+ const files: StorageFile[] = [];
127
+
128
+ const walk = async (dir: string): Promise<void> => {
129
+ try {
130
+ const entries = await fs.readdir(dir, { withFileTypes: true });
131
+ for (const entry of entries) {
132
+ const fullPath = path.join(dir, entry.name);
133
+ if (entry.isDirectory()) {
134
+ await walk(fullPath);
135
+ } else {
136
+ const relativePath = path.relative(this.basePath, fullPath);
137
+ const stat = await fs.stat(fullPath);
138
+ files.push({
139
+ name: entry.name,
140
+ path: this.sanitizePath(relativePath),
141
+ size: stat.size,
142
+ mimeType: this.getMimeType(entry.name),
143
+ lastModified: stat.mtime,
144
+ url: await this.url(this.sanitizePath(relativePath)),
145
+ });
146
+ }
147
+ }
148
+ } catch (e: any) {
149
+ if (e.code !== "ENOENT") throw e;
150
+ }
151
+ };
152
+
153
+ await walk(searchPath);
154
+ return files;
155
+ }
156
+
157
+ async url(filePath: string, _expiresIn?: number): Promise<string> {
158
+ const sanitizedPath = this.sanitizePath(filePath);
159
+ return `/storage/${sanitizedPath}`;
160
+ }
161
+ }
@@ -0,0 +1,232 @@
1
+ import { BaseStorageAdapter } from "../base";
2
+ import type { StorageConfig, StorageFile, PutOptions } from "../base";
3
+ import {
4
+ S3Client,
5
+ PutObjectCommand,
6
+ GetObjectCommand,
7
+ DeleteObjectCommand,
8
+ HeadObjectCommand,
9
+ HeadBucketCommand,
10
+ ListObjectsV2Command,
11
+ CopyObjectCommand,
12
+ } from "@aws-sdk/client-s3";
13
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
14
+
15
+ export class S3StorageAdapter extends BaseStorageAdapter {
16
+ private client: S3Client;
17
+ private bucket: string;
18
+
19
+ constructor(private config: StorageConfig) {
20
+ super();
21
+ const conn = config.connection || {};
22
+
23
+ if (conn.endpoint?.startsWith("http://") && conn.useSSL !== false) {
24
+ throw new Error(
25
+ "Refusing plaintext S3 endpoint; set useSSL:false to override"
26
+ );
27
+ }
28
+
29
+ this.bucket = conn.bucket || "iskra-storage";
30
+
31
+ this.client = new S3Client({
32
+ endpoint: conn.endpoint,
33
+ region: conn.region || "us-east-1",
34
+ credentials:
35
+ conn.accessKey && conn.secretKey
36
+ ? { accessKeyId: conn.accessKey, secretAccessKey: conn.secretKey }
37
+ : undefined,
38
+ forcePathStyle: !!conn.endpoint,
39
+ });
40
+ }
41
+
42
+ async connect(): Promise<void> {
43
+ try {
44
+ await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
45
+ this.connected = true;
46
+ } catch (err: any) {
47
+ throw new Error(`Failed to connect to S3 bucket "${this.bucket}": ${err.message}`);
48
+ }
49
+ }
50
+
51
+ async disconnect(): Promise<void> {
52
+ this.client.destroy();
53
+ this.connected = false;
54
+ }
55
+
56
+ async put(
57
+ path: string,
58
+ data: Uint8Array | Buffer | ReadableStream,
59
+ options?: PutOptions
60
+ ): Promise<StorageFile> {
61
+ this.ensureConnected();
62
+ const key = this.sanitizePath(path);
63
+
64
+ let body: Uint8Array | Buffer;
65
+ if (data instanceof ReadableStream) {
66
+ const response = new Response(data);
67
+ body = new Uint8Array(await response.arrayBuffer());
68
+ } else {
69
+ body = data;
70
+ }
71
+
72
+ await this.client.send(
73
+ new PutObjectCommand({
74
+ Bucket: this.bucket,
75
+ Key: key,
76
+ Body: body,
77
+ ContentType: options?.contentType || this.getMimeType(key),
78
+ Metadata: options?.metadata,
79
+ })
80
+ );
81
+
82
+ return {
83
+ name: key.split("/").pop() || key,
84
+ path: key,
85
+ size: body.length,
86
+ mimeType: options?.contentType || this.getMimeType(key),
87
+ lastModified: new Date(),
88
+ };
89
+ }
90
+
91
+ async get(path: string): Promise<Uint8Array | null> {
92
+ this.ensureConnected();
93
+ const key = this.sanitizePath(path);
94
+
95
+ try {
96
+ const response = await this.client.send(
97
+ new GetObjectCommand({ Bucket: this.bucket, Key: key })
98
+ );
99
+
100
+ if (!response.Body) return null;
101
+ return new Uint8Array(await response.Body.transformToByteArray());
102
+ } catch (err: any) {
103
+ if (err.name === "NoSuchKey" || err.$metadata?.httpStatusCode === 404) {
104
+ return null;
105
+ }
106
+ throw err;
107
+ }
108
+ }
109
+
110
+ async getStream(path: string): Promise<ReadableStream | null> {
111
+ this.ensureConnected();
112
+ const key = this.sanitizePath(path);
113
+
114
+ try {
115
+ const response = await this.client.send(
116
+ new GetObjectCommand({ Bucket: this.bucket, Key: key })
117
+ );
118
+
119
+ if (!response.Body) return null;
120
+
121
+ if (typeof (response.Body as any).transformToWebStream === "function") {
122
+ return (response.Body as any).transformToWebStream();
123
+ }
124
+
125
+ // Fallback: buffer then wrap in a ReadableStream
126
+ const bytes = await response.Body.transformToByteArray();
127
+ return new ReadableStream({
128
+ start(controller) {
129
+ controller.enqueue(bytes);
130
+ controller.close();
131
+ },
132
+ });
133
+ } catch (err: any) {
134
+ if (err.name === "NoSuchKey" || err.$metadata?.httpStatusCode === 404) {
135
+ return null;
136
+ }
137
+ throw err;
138
+ }
139
+ }
140
+
141
+ async delete(path: string): Promise<void> {
142
+ this.ensureConnected();
143
+ const key = this.sanitizePath(path);
144
+
145
+ await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
146
+ }
147
+
148
+ async exists(path: string): Promise<boolean> {
149
+ this.ensureConnected();
150
+ const key = this.sanitizePath(path);
151
+
152
+ try {
153
+ await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
154
+ return true;
155
+ } catch (err: any) {
156
+ if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
157
+ return false;
158
+ }
159
+ throw err;
160
+ }
161
+ }
162
+
163
+ async list(prefix?: string): Promise<StorageFile[]> {
164
+ this.ensureConnected();
165
+ const files: StorageFile[] = [];
166
+ let continuationToken: string | undefined;
167
+
168
+ do {
169
+ const response = await this.client.send(
170
+ new ListObjectsV2Command({
171
+ Bucket: this.bucket,
172
+ Prefix: prefix ? this.sanitizePath(prefix) : undefined,
173
+ ContinuationToken: continuationToken,
174
+ })
175
+ );
176
+
177
+ if (response.Contents) {
178
+ for (const obj of response.Contents) {
179
+ if (!obj.Key) continue;
180
+ files.push({
181
+ name: obj.Key.split("/").pop() || obj.Key,
182
+ path: obj.Key,
183
+ size: obj.Size || 0,
184
+ mimeType: this.getMimeType(obj.Key),
185
+ lastModified: obj.LastModified,
186
+ });
187
+ }
188
+ }
189
+
190
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
191
+ } while (continuationToken);
192
+
193
+ return files;
194
+ }
195
+
196
+ async url(path: string, expiresIn: number = 3600): Promise<string> {
197
+ this.ensureConnected();
198
+ const key = this.sanitizePath(path);
199
+
200
+ const command = new GetObjectCommand({ Bucket: this.bucket, Key: key });
201
+ return await getSignedUrl(this.client, command, { expiresIn });
202
+ }
203
+
204
+ async copy(from: string, to: string): Promise<void> {
205
+ this.ensureConnected();
206
+ const sourceKey = this.sanitizePath(from);
207
+ const destKey = this.sanitizePath(to);
208
+
209
+ await this.client.send(
210
+ new CopyObjectCommand({
211
+ Bucket: this.bucket,
212
+ CopySource: `${this.bucket}/${sourceKey}`,
213
+ Key: destKey,
214
+ })
215
+ );
216
+ }
217
+
218
+ async isDirectory(path: string): Promise<boolean> {
219
+ this.ensureConnected();
220
+ const prefix = this.sanitizePath(path).replace(/\/?$/, "/");
221
+
222
+ const response = await this.client.send(
223
+ new ListObjectsV2Command({
224
+ Bucket: this.bucket,
225
+ Prefix: prefix,
226
+ MaxKeys: 1,
227
+ })
228
+ );
229
+
230
+ return (response.Contents?.length || 0) > 0;
231
+ }
232
+ }
package/src/base.ts ADDED
@@ -0,0 +1,110 @@
1
+ export interface StorageConfig {
2
+ adapter: "local" | "minio" | "s3";
3
+ basePath?: string;
4
+ connection?: {
5
+ endpoint?: string;
6
+ accessKey?: string;
7
+ secretKey?: string;
8
+ bucket?: string;
9
+ region?: string;
10
+ useSSL?: boolean;
11
+ };
12
+ }
13
+
14
+ export interface StorageFile {
15
+ name: string;
16
+ path: string;
17
+ size: number;
18
+ mimeType?: string;
19
+ lastModified?: Date;
20
+ url?: string;
21
+ }
22
+
23
+ export interface PutOptions {
24
+ contentType?: string;
25
+ metadata?: Record<string, string>;
26
+ public?: boolean;
27
+ }
28
+
29
+ export interface StorageAdapter {
30
+ connect(): Promise<void>;
31
+ disconnect(): Promise<void>;
32
+ put(path: string, data: Uint8Array | Buffer | ReadableStream, options?: PutOptions): Promise<StorageFile>;
33
+ get(path: string): Promise<Uint8Array | null>;
34
+ getStream(path: string): Promise<ReadableStream | null>;
35
+ delete(path: string): Promise<void>;
36
+ exists(path: string): Promise<boolean>;
37
+ list(prefix?: string): Promise<StorageFile[]>;
38
+ url(path: string, expiresIn?: number): Promise<string>;
39
+ copy(from: string, to: string): Promise<void>;
40
+ move(from: string, to: string): Promise<void>;
41
+ isDirectory(path: string): Promise<boolean>;
42
+ }
43
+
44
+ export abstract class BaseStorageAdapter implements StorageAdapter {
45
+ protected connected = false;
46
+
47
+ abstract connect(): Promise<void>;
48
+ abstract disconnect(): Promise<void>;
49
+ abstract put(path: string, data: Uint8Array | Buffer | ReadableStream, options?: PutOptions): Promise<StorageFile>;
50
+ abstract get(path: string): Promise<Uint8Array | null>;
51
+ abstract getStream(path: string): Promise<ReadableStream | null>;
52
+ abstract delete(path: string): Promise<void>;
53
+ abstract exists(path: string): Promise<boolean>;
54
+ abstract list(prefix?: string): Promise<StorageFile[]>;
55
+ abstract url(path: string, expiresIn?: number): Promise<string>;
56
+ abstract isDirectory(path: string): Promise<boolean>;
57
+
58
+ isConnected(): boolean {
59
+ return this.connected;
60
+ }
61
+
62
+ protected ensureConnected(): void {
63
+ if (!this.connected) {
64
+ throw new Error("Storage not connected. Call connect() first.");
65
+ }
66
+ }
67
+
68
+ async copy(from: string, to: string): Promise<void> {
69
+ const data = await this.get(from);
70
+ if (!data) {
71
+ throw new Error(`Source file not found: ${from}`);
72
+ }
73
+ await this.put(to, data);
74
+ }
75
+
76
+ async move(from: string, to: string): Promise<void> {
77
+ await this.copy(from, to);
78
+ await this.delete(from);
79
+ }
80
+
81
+ protected generateFileName(originalName: string): string {
82
+ const ext = originalName.split(".").pop();
83
+ const timestamp = Date.now();
84
+ const random = crypto.randomUUID().replace(/-/g, "");
85
+ return `${timestamp}-${random}.${ext}`;
86
+ }
87
+
88
+ protected sanitizePath(path: string): string {
89
+ const normalized = path.replace(/\\/g, "/");
90
+ const segments = normalized
91
+ .split("/")
92
+ .filter((segment) => segment !== "" && !/^\.+$/.test(segment));
93
+ return segments.join("/");
94
+ }
95
+
96
+ protected getMimeType(filename: string): string {
97
+ const ext = filename.split(".").pop()?.toLowerCase();
98
+ const mimeTypes: Record<string, string> = {
99
+ jpg: "image/jpeg",
100
+ jpeg: "image/jpeg",
101
+ png: "image/png",
102
+ gif: "image/gif",
103
+ pdf: "application/pdf",
104
+ txt: "text/plain",
105
+ json: "application/json",
106
+ zip: "application/zip",
107
+ };
108
+ return mimeTypes[ext || ""] || "application/octet-stream";
109
+ }
110
+ }
package/src/factory.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { StorageConfig } from "./base";
2
+ import { BaseStorageAdapter } from "./base";
3
+ import { LocalStorageAdapter } from "./adapters/local";
4
+
5
+ /**
6
+ * Crea e inicializa el adaptador de almacenamiento correcto segun la configuracion.
7
+ * El adaptador de S3/MinIO se importa de forma lazy para evitar cargar el SDK de AWS
8
+ * en entornos que solo usan almacenamiento local.
9
+ */
10
+ export async function createStorageAdapter(
11
+ config: StorageConfig
12
+ ): Promise<BaseStorageAdapter> {
13
+ if (config.adapter === "local") {
14
+ return new LocalStorageAdapter(config);
15
+ }
16
+
17
+ if (config.adapter === "s3" || config.adapter === "minio") {
18
+ const { S3StorageAdapter } = await import("./adapters/s3");
19
+ return new S3StorageAdapter(config);
20
+ }
21
+
22
+ throw new Error(
23
+ `Adaptador de almacenamiento no soportado: "${config.adapter}". Usa "local", "s3" o "minio".`
24
+ );
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export type { StorageConfig, StorageFile, PutOptions, StorageAdapter } from "./base";
2
+ export { BaseStorageAdapter } from "./base";
3
+ export { LocalStorageAdapter } from "./adapters/local";
4
+ export { S3StorageAdapter } from "./adapters/s3";
5
+ export { createStorageAdapter } from "./factory";