@opensaas/stack-storage-s3 0.1.1
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/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +145 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
- package/src/index.ts +211 -0
- package/tests/s3-storage-provider.test.ts +780 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +14 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 OpenSaas Stack Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# @opensaas/stack-storage-s3
|
|
2
|
+
|
|
3
|
+
AWS S3 storage provider for OpenSaas Stack file uploads.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @opensaas/stack-storage @opensaas/stack-storage-s3
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
// opensaas.config.ts
|
|
15
|
+
import { config, list } from '@opensaas/stack-core'
|
|
16
|
+
import { s3Storage } from '@opensaas/stack-storage-s3'
|
|
17
|
+
import { image } from '@opensaas/stack-storage/fields'
|
|
18
|
+
|
|
19
|
+
export default config({
|
|
20
|
+
storage: {
|
|
21
|
+
avatars: s3Storage({
|
|
22
|
+
bucket: 'my-avatars',
|
|
23
|
+
region: 'us-east-1',
|
|
24
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
25
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
26
|
+
}),
|
|
27
|
+
},
|
|
28
|
+
lists: {
|
|
29
|
+
User: list({
|
|
30
|
+
fields: {
|
|
31
|
+
avatar: image({ storage: 'avatars' }),
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Configuration Options
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
s3Storage({
|
|
42
|
+
// Required
|
|
43
|
+
bucket: string // S3 bucket name
|
|
44
|
+
region: string // AWS region (e.g., 'us-east-1')
|
|
45
|
+
|
|
46
|
+
// Optional - Authentication
|
|
47
|
+
accessKeyId?: string // AWS access key (or use IAM role)
|
|
48
|
+
secretAccessKey?: string // AWS secret key (or use IAM role)
|
|
49
|
+
|
|
50
|
+
// Optional - S3-compatible services
|
|
51
|
+
endpoint?: string // Custom endpoint (e.g., 'https://s3.backblazeb2.com')
|
|
52
|
+
forcePathStyle?: boolean // Force path-style URLs (required for some services)
|
|
53
|
+
|
|
54
|
+
// Optional - Storage options
|
|
55
|
+
pathPrefix?: string // Prefix for all files (e.g., 'uploads/')
|
|
56
|
+
generateUniqueFilenames?: boolean // Generate unique filenames (default: true)
|
|
57
|
+
acl?: string // ACL for uploaded files (default: 'private')
|
|
58
|
+
|
|
59
|
+
// Optional - Public URLs
|
|
60
|
+
customDomain?: string // Custom domain for public URLs (e.g., 'https://cdn.example.com')
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Examples
|
|
65
|
+
|
|
66
|
+
### Standard AWS S3
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
avatars: s3Storage({
|
|
70
|
+
bucket: 'my-bucket',
|
|
71
|
+
region: 'us-east-1',
|
|
72
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
73
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
74
|
+
acl: 'public-read',
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### With IAM Role (No Keys)
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
avatars: s3Storage({
|
|
82
|
+
bucket: 'my-bucket',
|
|
83
|
+
region: 'us-east-1',
|
|
84
|
+
// No accessKeyId/secretAccessKey - uses IAM role
|
|
85
|
+
})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### With CloudFront CDN
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
avatars: s3Storage({
|
|
92
|
+
bucket: 'my-bucket',
|
|
93
|
+
region: 'us-east-1',
|
|
94
|
+
customDomain: 'https://d123456789.cloudfront.net',
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Backblaze B2
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
files: s3Storage({
|
|
102
|
+
bucket: 'my-bucket',
|
|
103
|
+
region: 'us-west-000',
|
|
104
|
+
endpoint: 'https://s3.us-west-000.backblazeb2.com',
|
|
105
|
+
forcePathStyle: true,
|
|
106
|
+
accessKeyId: process.env.B2_KEY_ID,
|
|
107
|
+
secretAccessKey: process.env.B2_APPLICATION_KEY,
|
|
108
|
+
})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### MinIO (Self-Hosted)
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
files: s3Storage({
|
|
115
|
+
bucket: 'my-bucket',
|
|
116
|
+
region: 'us-east-1',
|
|
117
|
+
endpoint: 'http://localhost:9000',
|
|
118
|
+
forcePathStyle: true,
|
|
119
|
+
accessKeyId: 'minioadmin',
|
|
120
|
+
secretAccessKey: 'minioadmin',
|
|
121
|
+
})
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### DigitalOcean Spaces
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
files: s3Storage({
|
|
128
|
+
bucket: 'my-space',
|
|
129
|
+
region: 'nyc3',
|
|
130
|
+
endpoint: 'https://nyc3.digitaloceanspaces.com',
|
|
131
|
+
forcePathStyle: false,
|
|
132
|
+
accessKeyId: process.env.DO_SPACES_KEY,
|
|
133
|
+
secretAccessKey: process.env.DO_SPACES_SECRET,
|
|
134
|
+
customDomain: 'https://my-space.nyc3.cdn.digitaloceanspaces.com',
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## ACL Options
|
|
139
|
+
|
|
140
|
+
- `'private'` - Only bucket owner has access (default)
|
|
141
|
+
- `'public-read'` - Public read access
|
|
142
|
+
- `'public-read-write'` - Public read/write access
|
|
143
|
+
- `'authenticated-read'` - Authenticated AWS users have read access
|
|
144
|
+
|
|
145
|
+
## Signed URLs
|
|
146
|
+
|
|
147
|
+
The S3 provider supports signed URLs for private files:
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
import { createStorageProvider } from '@opensaas/stack-storage/runtime'
|
|
151
|
+
|
|
152
|
+
const provider = createStorageProvider(config, 'avatars')
|
|
153
|
+
|
|
154
|
+
// Get signed URL valid for 1 hour
|
|
155
|
+
const signedUrl = await provider.getSignedUrl('filename.jpg', 3600)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Environment Variables
|
|
159
|
+
|
|
160
|
+
Recommended setup:
|
|
161
|
+
|
|
162
|
+
```env
|
|
163
|
+
AWS_ACCESS_KEY_ID=your-access-key
|
|
164
|
+
AWS_SECRET_ACCESS_KEY=your-secret-key
|
|
165
|
+
AWS_REGION=us-east-1
|
|
166
|
+
AWS_BUCKET=your-bucket
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { StorageProvider, UploadOptions, UploadResult } from '@opensaas/stack-storage';
|
|
2
|
+
/**
|
|
3
|
+
* Configuration for S3 storage
|
|
4
|
+
*/
|
|
5
|
+
export interface S3StorageConfig {
|
|
6
|
+
type: 's3';
|
|
7
|
+
/** S3 bucket name */
|
|
8
|
+
bucket: string;
|
|
9
|
+
/** AWS region */
|
|
10
|
+
region: string;
|
|
11
|
+
/** AWS access key ID (optional if using IAM role) */
|
|
12
|
+
accessKeyId?: string;
|
|
13
|
+
/** AWS secret access key (optional if using IAM role) */
|
|
14
|
+
secretAccessKey?: string;
|
|
15
|
+
/** Custom endpoint for S3-compatible services (e.g., MinIO, Backblaze) */
|
|
16
|
+
endpoint?: string;
|
|
17
|
+
/** Force path style URLs (required for some S3-compatible services) */
|
|
18
|
+
forcePathStyle?: boolean;
|
|
19
|
+
/** Base path prefix for all uploaded files */
|
|
20
|
+
pathPrefix?: string;
|
|
21
|
+
/** Whether to generate unique filenames (default: true) */
|
|
22
|
+
generateUniqueFilenames?: boolean;
|
|
23
|
+
/** ACL for uploaded files (default: 'private') */
|
|
24
|
+
acl?: 'private' | 'public-read' | 'public-read-write' | 'authenticated-read';
|
|
25
|
+
/** Custom domain for public URLs (e.g., 'https://cdn.example.com') */
|
|
26
|
+
customDomain?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* AWS S3 storage provider
|
|
30
|
+
* Supports standard S3 and S3-compatible services
|
|
31
|
+
*/
|
|
32
|
+
export declare class S3StorageProvider implements StorageProvider {
|
|
33
|
+
private client;
|
|
34
|
+
private config;
|
|
35
|
+
constructor(config: S3StorageConfig);
|
|
36
|
+
/**
|
|
37
|
+
* Generates a unique filename if configured
|
|
38
|
+
*/
|
|
39
|
+
private generateFilename;
|
|
40
|
+
/**
|
|
41
|
+
* Gets the full key for an object including path prefix
|
|
42
|
+
*/
|
|
43
|
+
private getFullKey;
|
|
44
|
+
upload(file: Buffer | Uint8Array, filename: string, options?: UploadOptions): Promise<UploadResult>;
|
|
45
|
+
download(filename: string): Promise<Buffer>;
|
|
46
|
+
delete(filename: string): Promise<void>;
|
|
47
|
+
getUrl(filename: string): string;
|
|
48
|
+
getSignedUrl(filename: string, expiresIn?: number): Promise<string>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Creates an S3 storage configuration
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* const config = config({
|
|
56
|
+
* storage: {
|
|
57
|
+
* avatars: s3Storage({
|
|
58
|
+
* bucket: 'my-avatars',
|
|
59
|
+
* region: 'us-east-1',
|
|
60
|
+
* accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
61
|
+
* secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
62
|
+
* }),
|
|
63
|
+
* },
|
|
64
|
+
* })
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export declare function s3Storage(config: Omit<S3StorageConfig, 'type'>): S3StorageConfig;
|
|
68
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAE3F;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,IAAI,CAAA;IACV,qBAAqB;IACrB,MAAM,EAAE,MAAM,CAAA;IACd,iBAAiB;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,yDAAyD;IACzD,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uEAAuE;IACvE,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,8CAA8C;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,2DAA2D;IAC3D,uBAAuB,CAAC,EAAE,OAAO,CAAA;IACjC,kDAAkD;IAClD,GAAG,CAAC,EAAE,SAAS,GAAG,aAAa,GAAG,mBAAmB,GAAG,oBAAoB,CAAA;IAC5E,sEAAsE;IACtE,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED;;;GAGG;AACH,qBAAa,iBAAkB,YAAW,eAAe;IACvD,OAAO,CAAC,MAAM,CAAU;IACxB,OAAO,CAAC,MAAM,CAAiB;gBAEnB,MAAM,EAAE,eAAe;IAkBnC;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAWxB;;OAEG;IACH,OAAO,CAAC,UAAU;IAOZ,MAAM,CACV,IAAI,EAAE,MAAM,GAAG,UAAU,EACzB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,aAAa,GACtB,OAAO,CAAC,YAAY,CAAC;IA6BlB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAyB3C,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7C,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;IAkB1B,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,GAAE,MAAa,GAAG,OAAO,CAAC,MAAM,CAAC;CAUhF;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,CAAC,GAAG,eAAe,CAOhF"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, } from '@aws-sdk/client-s3';
|
|
2
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
/**
|
|
5
|
+
* AWS S3 storage provider
|
|
6
|
+
* Supports standard S3 and S3-compatible services
|
|
7
|
+
*/
|
|
8
|
+
export class S3StorageProvider {
|
|
9
|
+
client;
|
|
10
|
+
config;
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
// Create S3 client
|
|
14
|
+
this.client = new S3Client({
|
|
15
|
+
region: config.region,
|
|
16
|
+
credentials: config.accessKeyId && config.secretAccessKey
|
|
17
|
+
? {
|
|
18
|
+
accessKeyId: config.accessKeyId,
|
|
19
|
+
secretAccessKey: config.secretAccessKey,
|
|
20
|
+
}
|
|
21
|
+
: undefined,
|
|
22
|
+
endpoint: config.endpoint,
|
|
23
|
+
forcePathStyle: config.forcePathStyle,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Generates a unique filename if configured
|
|
28
|
+
*/
|
|
29
|
+
generateFilename(originalFilename) {
|
|
30
|
+
if (this.config.generateUniqueFilenames === false) {
|
|
31
|
+
return originalFilename;
|
|
32
|
+
}
|
|
33
|
+
const ext = originalFilename.substring(originalFilename.lastIndexOf('.'));
|
|
34
|
+
const uniqueId = randomBytes(16).toString('hex');
|
|
35
|
+
const timestamp = Date.now();
|
|
36
|
+
return `${timestamp}-${uniqueId}${ext}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Gets the full key for an object including path prefix
|
|
40
|
+
*/
|
|
41
|
+
getFullKey(filename) {
|
|
42
|
+
if (this.config.pathPrefix) {
|
|
43
|
+
return `${this.config.pathPrefix}/${filename}`;
|
|
44
|
+
}
|
|
45
|
+
return filename;
|
|
46
|
+
}
|
|
47
|
+
async upload(file, filename, options) {
|
|
48
|
+
const generatedFilename = this.generateFilename(filename);
|
|
49
|
+
const key = this.getFullKey(generatedFilename);
|
|
50
|
+
// Upload to S3
|
|
51
|
+
const command = new PutObjectCommand({
|
|
52
|
+
Bucket: this.config.bucket,
|
|
53
|
+
Key: key,
|
|
54
|
+
Body: file,
|
|
55
|
+
ContentType: options?.contentType,
|
|
56
|
+
Metadata: options?.metadata,
|
|
57
|
+
ACL: this.config.acl || 'private',
|
|
58
|
+
CacheControl: options?.cacheControl,
|
|
59
|
+
});
|
|
60
|
+
await this.client.send(command);
|
|
61
|
+
// Generate URL
|
|
62
|
+
const url = this.getUrl(generatedFilename);
|
|
63
|
+
return {
|
|
64
|
+
filename: generatedFilename,
|
|
65
|
+
url,
|
|
66
|
+
size: file.length,
|
|
67
|
+
contentType: options?.contentType || 'application/octet-stream',
|
|
68
|
+
metadata: options?.metadata,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async download(filename) {
|
|
72
|
+
const key = this.getFullKey(filename);
|
|
73
|
+
const command = new GetObjectCommand({
|
|
74
|
+
Bucket: this.config.bucket,
|
|
75
|
+
Key: key,
|
|
76
|
+
});
|
|
77
|
+
const response = await this.client.send(command);
|
|
78
|
+
if (!response.Body) {
|
|
79
|
+
throw new Error(`File not found: ${filename}`);
|
|
80
|
+
}
|
|
81
|
+
// Convert stream to buffer
|
|
82
|
+
const chunks = [];
|
|
83
|
+
const stream = response.Body;
|
|
84
|
+
for await (const chunk of stream) {
|
|
85
|
+
chunks.push(chunk);
|
|
86
|
+
}
|
|
87
|
+
return Buffer.concat(chunks);
|
|
88
|
+
}
|
|
89
|
+
async delete(filename) {
|
|
90
|
+
const key = this.getFullKey(filename);
|
|
91
|
+
const command = new DeleteObjectCommand({
|
|
92
|
+
Bucket: this.config.bucket,
|
|
93
|
+
Key: key,
|
|
94
|
+
});
|
|
95
|
+
await this.client.send(command);
|
|
96
|
+
}
|
|
97
|
+
getUrl(filename) {
|
|
98
|
+
const key = this.getFullKey(filename);
|
|
99
|
+
// Use custom domain if configured
|
|
100
|
+
if (this.config.customDomain) {
|
|
101
|
+
return `${this.config.customDomain}/${key}`;
|
|
102
|
+
}
|
|
103
|
+
// Use standard S3 URL
|
|
104
|
+
if (this.config.endpoint) {
|
|
105
|
+
// Custom endpoint (S3-compatible services)
|
|
106
|
+
return `${this.config.endpoint}/${this.config.bucket}/${key}`;
|
|
107
|
+
}
|
|
108
|
+
// Standard AWS S3 URL
|
|
109
|
+
return `https://${this.config.bucket}.s3.${this.config.region}.amazonaws.com/${key}`;
|
|
110
|
+
}
|
|
111
|
+
async getSignedUrl(filename, expiresIn = 3600) {
|
|
112
|
+
const key = this.getFullKey(filename);
|
|
113
|
+
const command = new GetObjectCommand({
|
|
114
|
+
Bucket: this.config.bucket,
|
|
115
|
+
Key: key,
|
|
116
|
+
});
|
|
117
|
+
return await getSignedUrl(this.client, command, { expiresIn });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Creates an S3 storage configuration
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* ```typescript
|
|
125
|
+
* const config = config({
|
|
126
|
+
* storage: {
|
|
127
|
+
* avatars: s3Storage({
|
|
128
|
+
* bucket: 'my-avatars',
|
|
129
|
+
* region: 'us-east-1',
|
|
130
|
+
* accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
131
|
+
* secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
132
|
+
* }),
|
|
133
|
+
* },
|
|
134
|
+
* })
|
|
135
|
+
* ```
|
|
136
|
+
*/
|
|
137
|
+
export function s3Storage(config) {
|
|
138
|
+
return {
|
|
139
|
+
type: 's3',
|
|
140
|
+
generateUniqueFilenames: true,
|
|
141
|
+
acl: 'private',
|
|
142
|
+
...config,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opensaas/stack-storage-s3",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "AWS S3 storage provider for OpenSaas Stack file uploads",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"default": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@aws-sdk/client-s3": "^3.709.0",
|
|
16
|
+
"@aws-sdk/s3-request-presigner": "^3.709.0",
|
|
17
|
+
"@opensaas/stack-storage": "0.1.1"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^24.0.0",
|
|
21
|
+
"@vitest/coverage-v8": "^4.0.5",
|
|
22
|
+
"typescript": "^5.7.3",
|
|
23
|
+
"vitest": "^4.0.5"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@opensaas/stack-storage": "0.1.1"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"dev": "tsc --watch",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:ui": "vitest --ui",
|
|
33
|
+
"test:coverage": "vitest run --coverage"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import {
|
|
2
|
+
S3Client,
|
|
3
|
+
PutObjectCommand,
|
|
4
|
+
GetObjectCommand,
|
|
5
|
+
DeleteObjectCommand,
|
|
6
|
+
} from '@aws-sdk/client-s3'
|
|
7
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
8
|
+
import { randomBytes } from 'node:crypto'
|
|
9
|
+
import type { StorageProvider, UploadOptions, UploadResult } from '@opensaas/stack-storage'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Configuration for S3 storage
|
|
13
|
+
*/
|
|
14
|
+
export interface S3StorageConfig {
|
|
15
|
+
type: 's3'
|
|
16
|
+
/** S3 bucket name */
|
|
17
|
+
bucket: string
|
|
18
|
+
/** AWS region */
|
|
19
|
+
region: string
|
|
20
|
+
/** AWS access key ID (optional if using IAM role) */
|
|
21
|
+
accessKeyId?: string
|
|
22
|
+
/** AWS secret access key (optional if using IAM role) */
|
|
23
|
+
secretAccessKey?: string
|
|
24
|
+
/** Custom endpoint for S3-compatible services (e.g., MinIO, Backblaze) */
|
|
25
|
+
endpoint?: string
|
|
26
|
+
/** Force path style URLs (required for some S3-compatible services) */
|
|
27
|
+
forcePathStyle?: boolean
|
|
28
|
+
/** Base path prefix for all uploaded files */
|
|
29
|
+
pathPrefix?: string
|
|
30
|
+
/** Whether to generate unique filenames (default: true) */
|
|
31
|
+
generateUniqueFilenames?: boolean
|
|
32
|
+
/** ACL for uploaded files (default: 'private') */
|
|
33
|
+
acl?: 'private' | 'public-read' | 'public-read-write' | 'authenticated-read'
|
|
34
|
+
/** Custom domain for public URLs (e.g., 'https://cdn.example.com') */
|
|
35
|
+
customDomain?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* AWS S3 storage provider
|
|
40
|
+
* Supports standard S3 and S3-compatible services
|
|
41
|
+
*/
|
|
42
|
+
export class S3StorageProvider implements StorageProvider {
|
|
43
|
+
private client: S3Client
|
|
44
|
+
private config: S3StorageConfig
|
|
45
|
+
|
|
46
|
+
constructor(config: S3StorageConfig) {
|
|
47
|
+
this.config = config
|
|
48
|
+
|
|
49
|
+
// Create S3 client
|
|
50
|
+
this.client = new S3Client({
|
|
51
|
+
region: config.region,
|
|
52
|
+
credentials:
|
|
53
|
+
config.accessKeyId && config.secretAccessKey
|
|
54
|
+
? {
|
|
55
|
+
accessKeyId: config.accessKeyId,
|
|
56
|
+
secretAccessKey: config.secretAccessKey,
|
|
57
|
+
}
|
|
58
|
+
: undefined,
|
|
59
|
+
endpoint: config.endpoint,
|
|
60
|
+
forcePathStyle: config.forcePathStyle,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generates a unique filename if configured
|
|
66
|
+
*/
|
|
67
|
+
private generateFilename(originalFilename: string): string {
|
|
68
|
+
if (this.config.generateUniqueFilenames === false) {
|
|
69
|
+
return originalFilename
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ext = originalFilename.substring(originalFilename.lastIndexOf('.'))
|
|
73
|
+
const uniqueId = randomBytes(16).toString('hex')
|
|
74
|
+
const timestamp = Date.now()
|
|
75
|
+
return `${timestamp}-${uniqueId}${ext}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Gets the full key for an object including path prefix
|
|
80
|
+
*/
|
|
81
|
+
private getFullKey(filename: string): string {
|
|
82
|
+
if (this.config.pathPrefix) {
|
|
83
|
+
return `${this.config.pathPrefix}/${filename}`
|
|
84
|
+
}
|
|
85
|
+
return filename
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async upload(
|
|
89
|
+
file: Buffer | Uint8Array,
|
|
90
|
+
filename: string,
|
|
91
|
+
options?: UploadOptions,
|
|
92
|
+
): Promise<UploadResult> {
|
|
93
|
+
const generatedFilename = this.generateFilename(filename)
|
|
94
|
+
const key = this.getFullKey(generatedFilename)
|
|
95
|
+
|
|
96
|
+
// Upload to S3
|
|
97
|
+
const command = new PutObjectCommand({
|
|
98
|
+
Bucket: this.config.bucket,
|
|
99
|
+
Key: key,
|
|
100
|
+
Body: file,
|
|
101
|
+
ContentType: options?.contentType,
|
|
102
|
+
Metadata: options?.metadata,
|
|
103
|
+
ACL: this.config.acl || 'private',
|
|
104
|
+
CacheControl: options?.cacheControl,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
await this.client.send(command)
|
|
108
|
+
|
|
109
|
+
// Generate URL
|
|
110
|
+
const url = this.getUrl(generatedFilename)
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
filename: generatedFilename,
|
|
114
|
+
url,
|
|
115
|
+
size: file.length,
|
|
116
|
+
contentType: options?.contentType || 'application/octet-stream',
|
|
117
|
+
metadata: options?.metadata,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async download(filename: string): Promise<Buffer> {
|
|
122
|
+
const key = this.getFullKey(filename)
|
|
123
|
+
|
|
124
|
+
const command = new GetObjectCommand({
|
|
125
|
+
Bucket: this.config.bucket,
|
|
126
|
+
Key: key,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const response = await this.client.send(command)
|
|
130
|
+
|
|
131
|
+
if (!response.Body) {
|
|
132
|
+
throw new Error(`File not found: ${filename}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Convert stream to buffer
|
|
136
|
+
const chunks: Uint8Array[] = []
|
|
137
|
+
const stream = response.Body as AsyncIterable<Uint8Array>
|
|
138
|
+
|
|
139
|
+
for await (const chunk of stream) {
|
|
140
|
+
chunks.push(chunk)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return Buffer.concat(chunks)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async delete(filename: string): Promise<void> {
|
|
147
|
+
const key = this.getFullKey(filename)
|
|
148
|
+
|
|
149
|
+
const command = new DeleteObjectCommand({
|
|
150
|
+
Bucket: this.config.bucket,
|
|
151
|
+
Key: key,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
await this.client.send(command)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
getUrl(filename: string): string {
|
|
158
|
+
const key = this.getFullKey(filename)
|
|
159
|
+
|
|
160
|
+
// Use custom domain if configured
|
|
161
|
+
if (this.config.customDomain) {
|
|
162
|
+
return `${this.config.customDomain}/${key}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Use standard S3 URL
|
|
166
|
+
if (this.config.endpoint) {
|
|
167
|
+
// Custom endpoint (S3-compatible services)
|
|
168
|
+
return `${this.config.endpoint}/${this.config.bucket}/${key}`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Standard AWS S3 URL
|
|
172
|
+
return `https://${this.config.bucket}.s3.${this.config.region}.amazonaws.com/${key}`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async getSignedUrl(filename: string, expiresIn: number = 3600): Promise<string> {
|
|
176
|
+
const key = this.getFullKey(filename)
|
|
177
|
+
|
|
178
|
+
const command = new GetObjectCommand({
|
|
179
|
+
Bucket: this.config.bucket,
|
|
180
|
+
Key: key,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return await getSignedUrl(this.client, command, { expiresIn })
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Creates an S3 storage configuration
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```typescript
|
|
192
|
+
* const config = config({
|
|
193
|
+
* storage: {
|
|
194
|
+
* avatars: s3Storage({
|
|
195
|
+
* bucket: 'my-avatars',
|
|
196
|
+
* region: 'us-east-1',
|
|
197
|
+
* accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
198
|
+
* secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
199
|
+
* }),
|
|
200
|
+
* },
|
|
201
|
+
* })
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
204
|
+
export function s3Storage(config: Omit<S3StorageConfig, 'type'>): S3StorageConfig {
|
|
205
|
+
return {
|
|
206
|
+
type: 's3',
|
|
207
|
+
generateUniqueFilenames: true,
|
|
208
|
+
acl: 'private',
|
|
209
|
+
...config,
|
|
210
|
+
}
|
|
211
|
+
}
|