@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.
@@ -0,0 +1,4 @@
1
+
2
+ > @opensaas/stack-storage-s3@0.1.1 build /home/runner/work/stack/stack/packages/storage-s3
3
+ > tsc
4
+
package/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # @opensaas/stack-storage-s3
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 045c071: Add field and image upload
8
+ - Updated dependencies [045c071]
9
+ - @opensaas/stack-storage@0.1.1
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
@@ -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
+ }