@nextlyhq/storage-s3 0.0.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/LICENSE +22 -0
- package/README.md +129 -0
- package/dist/index.cjs +475 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +873 -0
- package/dist/index.d.ts +873 -0
- package/dist/index.mjs +470 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +79 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NextlyHQ <info@nextlyhq.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
'Software'), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
19
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
20
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
21
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
22
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# @nextlyhq/storage-s3
|
|
2
|
+
|
|
3
|
+
Amazon S3 (and S3-compatible: Cloudflare R2, MinIO, Backblaze B2, Wasabi, DigitalOcean Spaces) storage adapter for Nextly.
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/@nextlyhq/storage-s3"><img alt="npm" src="https://img.shields.io/npm/v/@nextlyhq/storage-s3?style=flat-square&label=npm&color=cb3837" /></a>
|
|
7
|
+
<a href="https://github.com/nextlyhq/nextly/blob/main/LICENSE.md"><img alt="License" src="https://img.shields.io/github/license/nextlyhq/nextly?style=flat-square&color=blue" /></a>
|
|
8
|
+
<a href="https://nextlyhq.com/docs"><img alt="Status" src="https://img.shields.io/badge/status-alpha-orange?style=flat-square" /></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
> [!IMPORTANT]
|
|
12
|
+
> Nextly is in alpha. APIs may change before 1.0. Pin exact versions in production.
|
|
13
|
+
|
|
14
|
+
## What it is
|
|
15
|
+
|
|
16
|
+
Stores Nextly media uploads on Amazon S3 or any S3-compatible object store. R2, MinIO, B2, Wasabi, and DigitalOcean Spaces all work via the same adapter; you do not need separate packages.
|
|
17
|
+
|
|
18
|
+
> **You do not need this for development.** Nextly's default storage is local disk under `./public/uploads/`. Install this when you are ready to move uploads to a cloud object store, typically for production deployments.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm add @nextlyhq/storage-s3
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick usage
|
|
27
|
+
|
|
28
|
+
Register the storage adapter in `nextly.config.ts`:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { defineConfig } from "nextly/config";
|
|
32
|
+
import { s3Storage } from "@nextlyhq/storage-s3";
|
|
33
|
+
|
|
34
|
+
export default defineConfig({
|
|
35
|
+
storage: [
|
|
36
|
+
s3Storage({
|
|
37
|
+
bucket: process.env.S3_BUCKET!,
|
|
38
|
+
region: process.env.AWS_REGION!,
|
|
39
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
40
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
41
|
+
collections: { media: true },
|
|
42
|
+
}),
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Required environment variables
|
|
48
|
+
|
|
49
|
+
| Variable | Required? | Default | Notes |
|
|
50
|
+
| ----------------------- | --------------------------- | ------- | ----------------------------- |
|
|
51
|
+
| `S3_BUCKET` | yes | (none) | |
|
|
52
|
+
| `AWS_REGION` | yes for AWS | (none) | Use `auto` for Cloudflare R2. |
|
|
53
|
+
| `AWS_ACCESS_KEY_ID` | yes (if not using IAM role) | (none) | |
|
|
54
|
+
| `AWS_SECRET_ACCESS_KEY` | yes (if not using IAM role) | (none) | |
|
|
55
|
+
|
|
56
|
+
You can also pass these explicitly to `s3Storage(...)` instead of using env vars.
|
|
57
|
+
|
|
58
|
+
## Cloudflare R2
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
s3Storage({
|
|
62
|
+
bucket: process.env.R2_BUCKET!,
|
|
63
|
+
region: "auto",
|
|
64
|
+
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
|
65
|
+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
|
66
|
+
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
|
67
|
+
publicUrl: process.env.R2_PUBLIC_URL,
|
|
68
|
+
collections: { media: true },
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## MinIO
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
s3Storage({
|
|
76
|
+
bucket: "my-bucket",
|
|
77
|
+
region: "us-east-1",
|
|
78
|
+
endpoint: "https://minio.example.com",
|
|
79
|
+
forcePathStyle: true,
|
|
80
|
+
accessKeyId: process.env.MINIO_ACCESS_KEY!,
|
|
81
|
+
secretAccessKey: process.env.MINIO_SECRET_KEY!,
|
|
82
|
+
collections: { media: true },
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Per-collection configuration
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
s3Storage({
|
|
90
|
+
bucket: "my-bucket",
|
|
91
|
+
region: "us-east-1",
|
|
92
|
+
collections: {
|
|
93
|
+
media: true,
|
|
94
|
+
"private-docs": {
|
|
95
|
+
prefix: "private/",
|
|
96
|
+
clientUploads: true,
|
|
97
|
+
signedDownloads: true,
|
|
98
|
+
signedUrlExpiresIn: 3600,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Main exports
|
|
105
|
+
|
|
106
|
+
- `s3Storage`: plugin factory for `defineConfig.storage`
|
|
107
|
+
- `S3StorageAdapter`: the adapter class (advanced)
|
|
108
|
+
- Type exports: `S3StorageConfig`, `S3CollectionConfig`
|
|
109
|
+
|
|
110
|
+
## Compatibility
|
|
111
|
+
|
|
112
|
+
| Tool | Version |
|
|
113
|
+
| -------- | ----------------------------------------------------------------------- |
|
|
114
|
+
| Node.js | 20+ |
|
|
115
|
+
| `nextly` | 0.0.x |
|
|
116
|
+
| Stores | AWS S3, Cloudflare R2, MinIO, Backblaze B2, Wasabi, DigitalOcean Spaces |
|
|
117
|
+
|
|
118
|
+
## Documentation
|
|
119
|
+
|
|
120
|
+
- [**Media and storage docs**](https://nextlyhq.com/docs/guides/media-storage)
|
|
121
|
+
|
|
122
|
+
## Related packages
|
|
123
|
+
|
|
124
|
+
- [`@nextlyhq/storage-vercel-blob`](../storage-vercel-blob)
|
|
125
|
+
- [`@nextlyhq/storage-uploadthing`](../storage-uploadthing)
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
[MIT](../../LICENSE.md)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var clientS3 = require('@aws-sdk/client-s3');
|
|
4
|
+
var libStorage = require('@aws-sdk/lib-storage');
|
|
5
|
+
var s3RequestPresigner = require('@aws-sdk/s3-request-presigner');
|
|
6
|
+
|
|
7
|
+
// src/adapter.ts
|
|
8
|
+
var S3StorageAdapter = class {
|
|
9
|
+
/**
|
|
10
|
+
* Create a new S3 storage adapter.
|
|
11
|
+
*
|
|
12
|
+
* @param config - S3 storage configuration
|
|
13
|
+
* @throws Error if bucket or region is not provided
|
|
14
|
+
*/
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
if (!config.bucket) {
|
|
18
|
+
throw new Error("@nextly/storage-s3: bucket is required");
|
|
19
|
+
}
|
|
20
|
+
if (!config.region) {
|
|
21
|
+
throw new Error("@nextly/storage-s3: region is required");
|
|
22
|
+
}
|
|
23
|
+
this.isR2 = config.endpoint?.includes("r2.cloudflarestorage.com") ?? false;
|
|
24
|
+
this.resolvedConfig = {
|
|
25
|
+
bucket: config.bucket,
|
|
26
|
+
region: config.region,
|
|
27
|
+
endpoint: config.endpoint,
|
|
28
|
+
forcePathStyle: config.forcePathStyle ?? false,
|
|
29
|
+
acl: config.acl ?? "private",
|
|
30
|
+
publicUrl: config.publicUrl,
|
|
31
|
+
cacheControl: config.cacheControl ?? "public, max-age=31536000",
|
|
32
|
+
contentDisposition: config.contentDisposition,
|
|
33
|
+
signedUrlExpiresIn: config.signedUrlExpiresIn ?? 3600
|
|
34
|
+
};
|
|
35
|
+
const credentials = this.buildCredentials();
|
|
36
|
+
this.client = new clientS3.S3Client({
|
|
37
|
+
region: config.region,
|
|
38
|
+
endpoint: config.endpoint,
|
|
39
|
+
credentials,
|
|
40
|
+
forcePathStyle: this.resolvedConfig.forcePathStyle,
|
|
41
|
+
...config.config
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
client;
|
|
45
|
+
resolvedConfig;
|
|
46
|
+
isR2;
|
|
47
|
+
/**
|
|
48
|
+
* Build AWS credentials from config.
|
|
49
|
+
* Supports explicit credentials or falls back to SDK default chain.
|
|
50
|
+
*/
|
|
51
|
+
buildCredentials() {
|
|
52
|
+
if (this.config.accessKeyId && this.config.secretAccessKey) {
|
|
53
|
+
return {
|
|
54
|
+
accessKeyId: this.config.accessKeyId,
|
|
55
|
+
secretAccessKey: this.config.secretAccessKey
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (this.config.config?.credentials) {
|
|
59
|
+
return void 0;
|
|
60
|
+
}
|
|
61
|
+
return void 0;
|
|
62
|
+
}
|
|
63
|
+
// ============================================================
|
|
64
|
+
// Core IStorageAdapter Methods
|
|
65
|
+
// ============================================================
|
|
66
|
+
/**
|
|
67
|
+
* Upload file to S3.
|
|
68
|
+
*
|
|
69
|
+
* Uses AWS SDK v3's Upload class from @aws-sdk/lib-storage which:
|
|
70
|
+
* - Automatically handles multipart upload for large files (>5MB)
|
|
71
|
+
* - Provides progress tracking capability
|
|
72
|
+
* - Includes retry logic
|
|
73
|
+
* - Optimizes upload performance
|
|
74
|
+
*
|
|
75
|
+
* @param buffer - File content as Buffer
|
|
76
|
+
* @param options - Upload options (filename, mimeType, folder, collection)
|
|
77
|
+
* @returns Upload result with URL and storage path
|
|
78
|
+
*/
|
|
79
|
+
async upload(buffer, options) {
|
|
80
|
+
const key = this.generateKey(options.filename, options.folder);
|
|
81
|
+
const uploadParams = {
|
|
82
|
+
Bucket: this.resolvedConfig.bucket,
|
|
83
|
+
Key: key,
|
|
84
|
+
Body: buffer,
|
|
85
|
+
ContentType: options.contentType || options.mimeType,
|
|
86
|
+
CacheControl: this.resolvedConfig.cacheControl
|
|
87
|
+
};
|
|
88
|
+
if (!this.isR2) {
|
|
89
|
+
uploadParams.ACL = this.resolvedConfig.acl;
|
|
90
|
+
}
|
|
91
|
+
const disposition = options.contentDisposition ?? this.resolvedConfig.contentDisposition;
|
|
92
|
+
if (disposition) {
|
|
93
|
+
const filename = this.sanitizeFilename(options.filename);
|
|
94
|
+
uploadParams.ContentDisposition = disposition === "attachment" ? `attachment; filename="${filename}"` : "inline";
|
|
95
|
+
}
|
|
96
|
+
uploadParams.Metadata = {
|
|
97
|
+
"original-filename": options.filename
|
|
98
|
+
};
|
|
99
|
+
const upload = new libStorage.Upload({
|
|
100
|
+
client: this.client,
|
|
101
|
+
params: uploadParams
|
|
102
|
+
});
|
|
103
|
+
await upload.done();
|
|
104
|
+
return {
|
|
105
|
+
url: this.getPublicUrl(key),
|
|
106
|
+
path: key
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Delete file from S3.
|
|
111
|
+
*
|
|
112
|
+
* @param filePath - Storage path/key to delete
|
|
113
|
+
*/
|
|
114
|
+
async delete(filePath) {
|
|
115
|
+
const command = new clientS3.DeleteObjectCommand({
|
|
116
|
+
Bucket: this.resolvedConfig.bucket,
|
|
117
|
+
Key: filePath
|
|
118
|
+
});
|
|
119
|
+
await this.client.send(command);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Bulk delete files from S3 using a single API call per 1000 keys.
|
|
123
|
+
*
|
|
124
|
+
* Uses AWS SDK v3's DeleteObjectsCommand which supports up to 1000 keys per
|
|
125
|
+
* request. Automatically batches larger arrays and collects per-key results.
|
|
126
|
+
*
|
|
127
|
+
* @param filePaths - Storage paths/keys to delete
|
|
128
|
+
* @returns Object with arrays of successful and failed deletions
|
|
129
|
+
*/
|
|
130
|
+
async bulkDelete(filePaths) {
|
|
131
|
+
const successful = [];
|
|
132
|
+
const failed = [];
|
|
133
|
+
const maxBatchSize = 1e3;
|
|
134
|
+
for (let i = 0; i < filePaths.length; i += maxBatchSize) {
|
|
135
|
+
const batch = filePaths.slice(i, i + maxBatchSize);
|
|
136
|
+
const command = new clientS3.DeleteObjectsCommand({
|
|
137
|
+
Bucket: this.resolvedConfig.bucket,
|
|
138
|
+
Delete: {
|
|
139
|
+
Objects: batch.map((key) => ({ Key: key })),
|
|
140
|
+
Quiet: false
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
const response = await this.client.send(command);
|
|
144
|
+
if (response.Errors && response.Errors.length > 0) {
|
|
145
|
+
for (const err of response.Errors) {
|
|
146
|
+
if (err.Key) {
|
|
147
|
+
failed.push({
|
|
148
|
+
filePath: err.Key,
|
|
149
|
+
error: err.Message ?? "Unknown S3 delete error"
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (response.Deleted) {
|
|
155
|
+
for (const del of response.Deleted) {
|
|
156
|
+
if (del.Key) {
|
|
157
|
+
successful.push(del.Key);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { successful, failed };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Check if file exists in S3.
|
|
166
|
+
*
|
|
167
|
+
* Uses HeadObject command which is more efficient than GetObject
|
|
168
|
+
* for existence checks (doesn't download the file).
|
|
169
|
+
*
|
|
170
|
+
* @param filePath - Storage path/key to check
|
|
171
|
+
* @returns true if file exists, false otherwise
|
|
172
|
+
*/
|
|
173
|
+
async exists(filePath) {
|
|
174
|
+
try {
|
|
175
|
+
const command = new clientS3.HeadObjectCommand({
|
|
176
|
+
Bucket: this.resolvedConfig.bucket,
|
|
177
|
+
Key: filePath
|
|
178
|
+
});
|
|
179
|
+
await this.client.send(command);
|
|
180
|
+
return true;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (this.isNotFoundError(error)) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Get public URL for S3 file.
|
|
190
|
+
*
|
|
191
|
+
* Priority order:
|
|
192
|
+
* 1. Custom publicUrl (CDN or custom domain) if configured
|
|
193
|
+
* 2. Standard S3 URL based on region and bucket
|
|
194
|
+
*
|
|
195
|
+
* For R2: Requires publicUrl configuration (R2 has no default public URLs).
|
|
196
|
+
*
|
|
197
|
+
* @param filePath - Storage path/key
|
|
198
|
+
* @returns Public URL to access the file
|
|
199
|
+
* @throws Error if R2 is used without publicUrl configuration
|
|
200
|
+
*/
|
|
201
|
+
getPublicUrl(filePath) {
|
|
202
|
+
if (this.resolvedConfig.publicUrl) {
|
|
203
|
+
const baseUrl = this.resolvedConfig.publicUrl.replace(/\/$/, "");
|
|
204
|
+
return `${baseUrl}/${filePath}`;
|
|
205
|
+
}
|
|
206
|
+
if (this.isR2) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
"@nextly/storage-s3: Cloudflare R2 requires publicUrl configuration.\n\nR2 does not have default public URLs like AWS S3. Configure one of:\n1. Public bucket URL: publicUrl: 'https://pub-xxx.r2.dev'\n2. Custom domain: publicUrl: 'https://cdn.example.com'\n\nSet up public access in the Cloudflare R2 dashboard."
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return `https://${this.resolvedConfig.bucket}.s3.${this.resolvedConfig.region}.amazonaws.com/${filePath}`;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Get storage type identifier.
|
|
215
|
+
*
|
|
216
|
+
* Returns "s3" for all S3-compatible services (AWS S3, R2, MinIO, etc.)
|
|
217
|
+
* as they all use the S3 API.
|
|
218
|
+
*/
|
|
219
|
+
getType() {
|
|
220
|
+
return "s3";
|
|
221
|
+
}
|
|
222
|
+
// ============================================================
|
|
223
|
+
// Optional IStorageAdapter Methods
|
|
224
|
+
// ============================================================
|
|
225
|
+
/**
|
|
226
|
+
* Get adapter info including capabilities.
|
|
227
|
+
*
|
|
228
|
+
* @returns Adapter info with type, name, and capability flags
|
|
229
|
+
*/
|
|
230
|
+
getInfo() {
|
|
231
|
+
return {
|
|
232
|
+
type: "s3",
|
|
233
|
+
name: "S3StorageAdapter",
|
|
234
|
+
supportsSignedUrls: true,
|
|
235
|
+
supportsClientUploads: true
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Get file metadata from S3.
|
|
240
|
+
*
|
|
241
|
+
* Retrieves file information including size, content type, and timestamps
|
|
242
|
+
* using the HeadObject command.
|
|
243
|
+
*
|
|
244
|
+
* @param filePath - Storage path/key
|
|
245
|
+
* @returns File metadata or null if file not found
|
|
246
|
+
*/
|
|
247
|
+
async getMetadata(filePath) {
|
|
248
|
+
try {
|
|
249
|
+
const command = new clientS3.HeadObjectCommand({
|
|
250
|
+
Bucket: this.resolvedConfig.bucket,
|
|
251
|
+
Key: filePath
|
|
252
|
+
});
|
|
253
|
+
const response = await this.client.send(command);
|
|
254
|
+
const filename = filePath.split("/").pop() || filePath;
|
|
255
|
+
return {
|
|
256
|
+
id: filePath,
|
|
257
|
+
filename,
|
|
258
|
+
originalFilename: response.Metadata?.["original-filename"] || filename,
|
|
259
|
+
mimeType: response.ContentType || "application/octet-stream",
|
|
260
|
+
size: response.ContentLength || 0,
|
|
261
|
+
url: this.getPublicUrl(filePath),
|
|
262
|
+
createdAt: response.LastModified?.toISOString() || (/* @__PURE__ */ new Date()).toISOString()
|
|
263
|
+
};
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (this.isNotFoundError(error)) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Generate signed URL for temporary private file access.
|
|
273
|
+
*
|
|
274
|
+
* Creates a pre-signed GetObject URL that grants temporary read access
|
|
275
|
+
* to private files. Useful for serving files from private buckets.
|
|
276
|
+
*
|
|
277
|
+
* @param filePath - Storage path/key
|
|
278
|
+
* @param expiresIn - URL validity duration in seconds (default: 3600)
|
|
279
|
+
* @returns Pre-signed URL for downloading the file
|
|
280
|
+
*/
|
|
281
|
+
async getSignedUrl(filePath, expiresIn) {
|
|
282
|
+
const command = new clientS3.GetObjectCommand({
|
|
283
|
+
Bucket: this.resolvedConfig.bucket,
|
|
284
|
+
Key: filePath
|
|
285
|
+
});
|
|
286
|
+
return s3RequestPresigner.getSignedUrl(this.client, command, {
|
|
287
|
+
expiresIn: expiresIn ?? this.resolvedConfig.signedUrlExpiresIn
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Generate pre-signed URL for client-side uploads.
|
|
292
|
+
*
|
|
293
|
+
* Creates a pre-signed PutObject URL that allows direct uploads from
|
|
294
|
+
* the browser to S3, bypassing server-side upload limits (e.g., Vercel's 4.5MB).
|
|
295
|
+
*
|
|
296
|
+
* @param key - Storage path/key for the upload
|
|
297
|
+
* @param mimeType - MIME type of the file being uploaded
|
|
298
|
+
* @param expiresIn - URL validity duration in seconds (default: 3600)
|
|
299
|
+
* @returns Client upload data with URL, method, and headers
|
|
300
|
+
*/
|
|
301
|
+
async getPresignedUploadUrl(key, mimeType, expiresIn) {
|
|
302
|
+
const expiration = expiresIn ?? this.resolvedConfig.signedUrlExpiresIn;
|
|
303
|
+
const commandParams = {
|
|
304
|
+
Bucket: this.resolvedConfig.bucket,
|
|
305
|
+
Key: key,
|
|
306
|
+
ContentType: mimeType,
|
|
307
|
+
CacheControl: this.resolvedConfig.cacheControl
|
|
308
|
+
};
|
|
309
|
+
if (!this.isR2) {
|
|
310
|
+
commandParams.ACL = this.resolvedConfig.acl;
|
|
311
|
+
}
|
|
312
|
+
const command = new clientS3.PutObjectCommand(commandParams);
|
|
313
|
+
const uploadUrl = await s3RequestPresigner.getSignedUrl(this.client, command, {
|
|
314
|
+
expiresIn: expiration
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
uploadUrl,
|
|
318
|
+
path: key,
|
|
319
|
+
method: "PUT",
|
|
320
|
+
headers: {
|
|
321
|
+
"Content-Type": mimeType
|
|
322
|
+
},
|
|
323
|
+
expiresAt: new Date(Date.now() + expiration * 1e3)
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
// ============================================================
|
|
327
|
+
// Helper Methods
|
|
328
|
+
// ============================================================
|
|
329
|
+
/**
|
|
330
|
+
* Generate a unique storage key with date-based prefix.
|
|
331
|
+
*
|
|
332
|
+
* Creates keys in format: {folder}/{year}/{month}/{uuid}-{sanitized-filename}
|
|
333
|
+
*
|
|
334
|
+
* @param filename - Original filename (will be sanitized)
|
|
335
|
+
* @param folder - Optional folder/prefix for organizing uploads
|
|
336
|
+
* @returns Generated storage key
|
|
337
|
+
*/
|
|
338
|
+
generateKey(filename, folder) {
|
|
339
|
+
const sanitized = this.sanitizeFilename(filename);
|
|
340
|
+
const uuid = crypto.randomUUID();
|
|
341
|
+
const date = /* @__PURE__ */ new Date();
|
|
342
|
+
const year = date.getFullYear();
|
|
343
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
344
|
+
const prefix = folder ? `${folder}/${year}/${month}` : `${year}/${month}`;
|
|
345
|
+
return `${prefix}/${uuid}-${sanitized}`;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Sanitize filename to prevent directory traversal and S3 key issues.
|
|
349
|
+
*
|
|
350
|
+
* @param filename - Original filename
|
|
351
|
+
* @returns Sanitized filename safe for S3 keys
|
|
352
|
+
*/
|
|
353
|
+
sanitizeFilename(filename) {
|
|
354
|
+
const basename = filename.split(/[/\\]/).pop() || filename;
|
|
355
|
+
return basename.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Check if an error is a "not found" error from S3.
|
|
359
|
+
*
|
|
360
|
+
* @param error - Error to check
|
|
361
|
+
* @returns true if error indicates file not found
|
|
362
|
+
*/
|
|
363
|
+
isNotFoundError(error) {
|
|
364
|
+
if (error && typeof error === "object") {
|
|
365
|
+
const e = error;
|
|
366
|
+
return e.name === "NotFound" || e.$metadata?.httpStatusCode === 404;
|
|
367
|
+
}
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
// ============================================================
|
|
371
|
+
// Public Accessors
|
|
372
|
+
// ============================================================
|
|
373
|
+
/**
|
|
374
|
+
* Get the S3 client instance.
|
|
375
|
+
* Useful for advanced operations not covered by the adapter interface.
|
|
376
|
+
*/
|
|
377
|
+
getClient() {
|
|
378
|
+
return this.client;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Get the bucket name.
|
|
382
|
+
*/
|
|
383
|
+
getBucket() {
|
|
384
|
+
return this.resolvedConfig.bucket;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Get the AWS region.
|
|
388
|
+
*/
|
|
389
|
+
getRegion() {
|
|
390
|
+
return this.resolvedConfig.region;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Check if this adapter is configured for Cloudflare R2.
|
|
394
|
+
*/
|
|
395
|
+
isCloudflareR2() {
|
|
396
|
+
return this.isR2;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// src/plugin.ts
|
|
401
|
+
function s3Storage(config) {
|
|
402
|
+
if (config.enabled === false) {
|
|
403
|
+
return {
|
|
404
|
+
name: "s3-storage",
|
|
405
|
+
type: "s3",
|
|
406
|
+
collections: {},
|
|
407
|
+
adapter: null
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
const adapter = new S3StorageAdapter(config);
|
|
411
|
+
const plugin = {
|
|
412
|
+
name: "s3-storage",
|
|
413
|
+
type: "s3",
|
|
414
|
+
collections: config.collections,
|
|
415
|
+
adapter,
|
|
416
|
+
/**
|
|
417
|
+
* Generate a pre-signed URL for client-side uploads.
|
|
418
|
+
*
|
|
419
|
+
* This allows files to be uploaded directly from the browser to S3,
|
|
420
|
+
* bypassing server-side upload limits (e.g., Vercel's 4.5MB limit).
|
|
421
|
+
*
|
|
422
|
+
* @param filename - Original filename from the client
|
|
423
|
+
* @param mimeType - MIME type of the file
|
|
424
|
+
* @param collection - Collection slug this upload belongs to
|
|
425
|
+
* @returns Client upload data with pre-signed URL
|
|
426
|
+
*/
|
|
427
|
+
async getClientUploadUrl(filename, mimeType, collection) {
|
|
428
|
+
const collectionConfig = config.collections[collection];
|
|
429
|
+
const prefix = typeof collectionConfig === "object" ? collectionConfig.prefix : void 0;
|
|
430
|
+
const key = generateStorageKey(filename, prefix);
|
|
431
|
+
return adapter.getPresignedUploadUrl(key, mimeType);
|
|
432
|
+
},
|
|
433
|
+
/**
|
|
434
|
+
* Generate a signed URL for private file downloads.
|
|
435
|
+
*
|
|
436
|
+
* Creates a time-limited URL for accessing files in private buckets.
|
|
437
|
+
* Only works when collection has `signedDownloads: true`.
|
|
438
|
+
*
|
|
439
|
+
* @param path - Storage path/key of the file
|
|
440
|
+
* @param expiresIn - URL validity duration in seconds
|
|
441
|
+
* @returns Signed URL for downloading the file
|
|
442
|
+
*/
|
|
443
|
+
async getSignedDownloadUrl(path, expiresIn) {
|
|
444
|
+
return adapter.getSignedUrl(
|
|
445
|
+
path,
|
|
446
|
+
expiresIn ?? config.signedUrlExpiresIn ?? 3600
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
return plugin;
|
|
451
|
+
}
|
|
452
|
+
function generateStorageKey(filename, prefix) {
|
|
453
|
+
const sanitized = sanitizeFilename(filename);
|
|
454
|
+
const uuid = crypto.randomUUID();
|
|
455
|
+
const date = /* @__PURE__ */ new Date();
|
|
456
|
+
const year = date.getFullYear();
|
|
457
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
458
|
+
const keyPrefix = prefix ? `${prefix}${year}/${month}` : `${year}/${month}`;
|
|
459
|
+
return `${keyPrefix}/${uuid}-${sanitized}`;
|
|
460
|
+
}
|
|
461
|
+
function sanitizeFilename(filename) {
|
|
462
|
+
const basename = filename.split(/[/\\]/).pop() || filename;
|
|
463
|
+
return basename.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// src/index.ts
|
|
467
|
+
var PACKAGE_NAME = "@nextly/storage-s3";
|
|
468
|
+
var PACKAGE_VERSION = "0.1.0";
|
|
469
|
+
|
|
470
|
+
exports.PACKAGE_NAME = PACKAGE_NAME;
|
|
471
|
+
exports.PACKAGE_VERSION = PACKAGE_VERSION;
|
|
472
|
+
exports.S3StorageAdapter = S3StorageAdapter;
|
|
473
|
+
exports.s3Storage = s3Storage;
|
|
474
|
+
//# sourceMappingURL=index.cjs.map
|
|
475
|
+
//# sourceMappingURL=index.cjs.map
|