nestjs-r2-storage 1.3.2 → 1.4.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/README.md
CHANGED
|
@@ -1,446 +1,488 @@
|
|
|
1
|
-
# nestjs-r2-storage
|
|
2
|
-
|
|
3
|
-
[](https://www.npmjs.com/package/nestjs-r2-storage)
|
|
4
|
-
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
|
|
6
|
-
**Author:** Nurul Islam Rimon
|
|
7
|
-
**GitHub:** [https://github.com/nurulislamrimon/nestjs-r2-storage](https://github.com/nurulislamrimon/nestjs-r2-storage)
|
|
8
|
-
|
|
9
|
-

|
|
10
|
-
|
|
11
|
-
Production-ready NestJS module for Cloudflare R2 object storage management.
|
|
12
|
-
|
|
13
|
-
## Features
|
|
14
|
-
|
|
15
|
-
- **Signed Upload URLs** - Generate presigned URLs for direct file uploads
|
|
16
|
-
- **Signed Download URLs** - Generate presigned URLs for secure file downloads
|
|
17
|
-
- **File Deletion** - Delete files from R2 storage
|
|
18
|
-
- **Nested Field Support** - Handle paths like `shop.logo`, `profile.avatar`
|
|
19
|
-
- **Array Field Support** - Handle paths like `products[].image`, `gallery[].photo`
|
|
20
|
-
- **Storage Usage Tracking** - Track storage used, increased, and decreased
|
|
21
|
-
- **Full CRUD Lifecycle** - Create, Update, Delete file operations
|
|
22
|
-
- **Access Control Modes** - Control public vs signed URL access (`private`, `public-read`, `hybrid`)
|
|
23
|
-
|
|
24
|
-
## Access Control Modes
|
|
25
|
-
|
|
26
|
-
Cloudflare R2 does NOT enforce ACLs like AWS S3 - the R2 API ignores ACL headers. True security is achieved by controlling URL exposure.
|
|
27
|
-
|
|
28
|
-
### Modes
|
|
29
|
-
|
|
30
|
-
| Mode | Public URLs | Signed URLs | Use Case |
|
|
31
|
-
| ------------- | ----------- | ----------- | ------------------------------------- |
|
|
32
|
-
| `private` | Not allowed | Required | Maximum security - only signed access |
|
|
33
|
-
| `public-read` | Allowed | Optional | Public files (e.g., static assets) |
|
|
34
|
-
| `hybrid` | Allowed | Allowed | Mixed content (default) |
|
|
35
|
-
|
|
36
|
-
### Private Mode
|
|
37
|
-
|
|
38
|
-
Only presigned URLs are allowed. Public URL generation throws `AccessModeError`.
|
|
39
|
-
|
|
40
|
-
```typescript
|
|
41
|
-
R2StorageModule.forRoot({
|
|
42
|
-
// ... other options
|
|
43
|
-
accessMode: "private",
|
|
44
|
-
publicUrlBase: "https://cdn.example.com", // still configured but not used
|
|
45
|
-
});
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Response in private mode:
|
|
49
|
-
|
|
50
|
-
```json
|
|
51
|
-
{
|
|
52
|
-
"uploadUrl": "https://signed-url...",
|
|
53
|
-
"publicUrl": null
|
|
54
|
-
}
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
### Public-Read Mode
|
|
58
|
-
|
|
59
|
-
Public URLs are generated. Signed URLs are optional.
|
|
60
|
-
|
|
61
|
-
```typescript
|
|
62
|
-
R2StorageModule.forRoot({
|
|
63
|
-
// ... other options
|
|
64
|
-
accessMode: "public-read",
|
|
65
|
-
publicUrlBase: "https://cdn.example.com",
|
|
66
|
-
});
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
### Hybrid Mode (Default)
|
|
70
|
-
|
|
71
|
-
Both public and signed access are allowed for backward compatibility.
|
|
72
|
-
|
|
73
|
-
```typescript
|
|
74
|
-
R2StorageModule.forRoot({
|
|
75
|
-
// ... other options
|
|
76
|
-
accessMode: "hybrid", // default
|
|
77
|
-
});
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## Quick Start
|
|
81
|
-
|
|
82
|
-
### 1. Configure the Module
|
|
83
|
-
|
|
84
|
-
```typescript
|
|
85
|
-
// app.module.ts
|
|
86
|
-
import { Module } from "@nestjs/common";
|
|
87
|
-
import { R2StorageModule } from "nestjs-r2-storage";
|
|
88
|
-
|
|
89
|
-
@Module({
|
|
90
|
-
imports: [
|
|
91
|
-
R2StorageModule.forRoot({
|
|
92
|
-
endpoint: process.env.R2_ENDPOINT,
|
|
93
|
-
accessKeyId: process.env.R2_ACCESS_KEY,
|
|
94
|
-
secretAccessKey: process.env.R2_SECRET_KEY,
|
|
95
|
-
bucketName: process.env.R2_BUCKET,
|
|
96
|
-
region: "auto",
|
|
97
|
-
publicUrlBase: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${process.env.R2_BUCKET}`,
|
|
98
|
-
signedUrlExpiry: 3600,
|
|
99
|
-
}),
|
|
100
|
-
],
|
|
101
|
-
})
|
|
102
|
-
export class AppModule {}
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
### 2. Use in Your Service
|
|
106
|
-
|
|
107
|
-
```typescript
|
|
108
|
-
import { Injectable } from "@nestjs/common";
|
|
109
|
-
import {
|
|
110
|
-
PhotoManagerService,
|
|
111
|
-
PhotoField,
|
|
112
|
-
CloudflareService,
|
|
113
|
-
} from "nestjs-r2-storage";
|
|
114
|
-
|
|
115
|
-
@Injectable()
|
|
116
|
-
export class ProductService {
|
|
117
|
-
constructor(
|
|
118
|
-
private readonly photoManager: PhotoManagerService,
|
|
119
|
-
private readonly cloudflare: CloudflareService,
|
|
120
|
-
) {}
|
|
121
|
-
|
|
122
|
-
async createProduct(payload: any) {
|
|
123
|
-
const photoFields: PhotoField[] = [
|
|
124
|
-
{ field: "image", urlField: "image_url", sizeField: "image_size" },
|
|
125
|
-
{
|
|
126
|
-
field: "gallery[].photo",
|
|
127
|
-
urlField: "photo_url",
|
|
128
|
-
sizeField: "photo_size",
|
|
129
|
-
},
|
|
130
|
-
];
|
|
131
|
-
|
|
132
|
-
const result = await this.photoManager.createObjectWithPhotos(
|
|
133
|
-
payload,
|
|
134
|
-
photoFields,
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
// Return upload URLs to client for direct upload
|
|
138
|
-
return {
|
|
139
|
-
product: result.updatedPayload,
|
|
140
|
-
uploadUrls: result.uploadUrls,
|
|
141
|
-
totalStorageUsed: result.totalStorageUsed,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async getProduct(id: string) {
|
|
146
|
-
const product = await this.findProduct(id);
|
|
147
|
-
|
|
148
|
-
const photoFields: PhotoField[] = [
|
|
149
|
-
{ field: "image", urlField: "image_url" },
|
|
150
|
-
{ field: "gallery[].photo", urlField: "photo_url" },
|
|
151
|
-
];
|
|
152
|
-
|
|
153
|
-
return this.photoManager.appendPhotoUrls(product, photoFields);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
async updateProduct(id: string, payload: any) {
|
|
157
|
-
const existing = await this.findProduct(id);
|
|
158
|
-
|
|
159
|
-
const photoFields: PhotoField[] = [
|
|
160
|
-
{ field: "image", urlField: "image_url", sizeField: "image_size" },
|
|
161
|
-
];
|
|
162
|
-
|
|
163
|
-
const result = await this.photoManager.updateObjectWithPhotos(
|
|
164
|
-
payload,
|
|
165
|
-
existing,
|
|
166
|
-
photoFields,
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
return {
|
|
170
|
-
product: result.updatedPayload,
|
|
171
|
-
uploadUrls: result.uploadUrls,
|
|
172
|
-
storageIncrease: result.storageIncrease,
|
|
173
|
-
storageDecrease: result.storageDecrease,
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async deleteProduct(id: string) {
|
|
178
|
-
const product = await this.findProduct(id);
|
|
179
|
-
|
|
180
|
-
const photoFields: PhotoField[] = [
|
|
181
|
-
{ field: "image", urlField: "image_url" },
|
|
182
|
-
];
|
|
183
|
-
|
|
184
|
-
await this.photoManager.deletePhotosFromObject(product, photoFields);
|
|
185
|
-
await this.removeProduct(id);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
## API Reference
|
|
191
|
-
|
|
192
|
-
### CloudflareService
|
|
193
|
-
|
|
194
|
-
Direct R2 operations.
|
|
195
|
-
|
|
196
|
-
```typescript
|
|
197
|
-
// Generate upload URL
|
|
198
|
-
const uploadUrl = await cloudflare.getUploadUrl("avatar.png", 1024000);
|
|
199
|
-
|
|
200
|
-
// Generate download URL
|
|
201
|
-
const downloadUrl = await cloudflare.getDownloadUrl("uploads/avatar_123.png");
|
|
202
|
-
|
|
203
|
-
// Delete file
|
|
204
|
-
await cloudflare.deleteFile("uploads/avatar.png");
|
|
205
|
-
|
|
206
|
-
// Check if file exists
|
|
207
|
-
const exists = await cloudflare.fileExists("uploads/avatar.png");
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
### Presigned URL Security
|
|
211
|
-
|
|
212
|
-
The module uses secure presigned URL generation:
|
|
213
|
-
|
|
214
|
-
- **Content-Length is NOT signed** - Prevents `SignatureDoesNotMatch` errors (browsers calculate it differently)
|
|
215
|
-
- **Checksum headers disabled** - Uses `requestChecksumCalculation: "WHEN_REQUIRED"` to avoid R2 compatibility issues
|
|
216
|
-
- **Minimal signing** - Only signs `host` and `content-type` headers
|
|
217
|
-
|
|
218
|
-
```typescript
|
|
219
|
-
const result = await cloudflare.getUploadUrl("avatar.png", 1024000);
|
|
220
|
-
|
|
221
|
-
// result = {
|
|
222
|
-
// uploadUrl: "https://signed-url...",
|
|
223
|
-
// fileKey: "uploads/avatar_123.png",
|
|
224
|
-
// publicUrl: "https://cdn.example.com/uploads/avatar_123.png",
|
|
225
|
-
// mimeType: "image/png",
|
|
226
|
-
// sizeField: 1024000 // Use this for client-side validation before upload
|
|
227
|
-
// }
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
### PhotoManagerService
|
|
231
|
-
|
|
232
|
-
High-level photo management.
|
|
233
|
-
|
|
234
|
-
#### appendPhotoUrls()
|
|
235
|
-
|
|
236
|
-
Adds signed URLs to response objects.
|
|
237
|
-
|
|
238
|
-
```typescript
|
|
239
|
-
const photoFields: PhotoField[] = [
|
|
240
|
-
{ field: "avatar", urlField: "avatar_url" },
|
|
241
|
-
{ field: "shop.logo", urlField: "logo_url" },
|
|
242
|
-
{ field: "products[].image", urlField: "image_url" },
|
|
243
|
-
{ field: "gallery[].photo", urlField: "photo_url" },
|
|
244
|
-
];
|
|
245
|
-
|
|
246
|
-
const result = await photoManager.appendPhotoUrls(product, photoFields);
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
Input:
|
|
250
|
-
|
|
251
|
-
```json
|
|
252
|
-
{
|
|
253
|
-
"name": "Laptop",
|
|
254
|
-
"image": "laptop.png",
|
|
255
|
-
"gallery": [{ "photo": "photo1.jpg" }, { "photo": "photo2.jpg" }]
|
|
256
|
-
}
|
|
257
|
-
```
|
|
258
|
-
|
|
259
|
-
Output:
|
|
260
|
-
|
|
261
|
-
```json
|
|
262
|
-
{
|
|
263
|
-
"name": "Laptop",
|
|
264
|
-
"image": "laptop.png",
|
|
265
|
-
"image_url": "https://signed-url...",
|
|
266
|
-
"gallery": [
|
|
267
|
-
{ "photo": "photo1.jpg", "photo_url": "https://signed-url..." },
|
|
268
|
-
{ "photo": "photo2.jpg", "photo_url": "https://signed-url..." }
|
|
269
|
-
]
|
|
270
|
-
}
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
#### createObjectWithPhotos()
|
|
274
|
-
|
|
275
|
-
Creates object with photo upload URLs.
|
|
276
|
-
|
|
277
|
-
```typescript
|
|
278
|
-
const payload = {
|
|
279
|
-
name: "Laptop",
|
|
280
|
-
image: "laptop.png",
|
|
281
|
-
image_size: 42000,
|
|
282
|
-
gallery: [
|
|
283
|
-
{ photo: "photo1.jpg", photo_size: 10000 },
|
|
284
|
-
{ photo: "photo2.jpg", photo_size: 15000 },
|
|
285
|
-
],
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
const photoFields: PhotoField[] = [
|
|
289
|
-
{ field: "image", sizeField: "image_size" },
|
|
290
|
-
{ field: "gallery[].photo", sizeField: "gallery[].photo_size" },
|
|
291
|
-
];
|
|
292
|
-
|
|
293
|
-
const result = await photoManager.createObjectWithPhotos(payload, photoFields);
|
|
294
|
-
|
|
295
|
-
// result = {
|
|
296
|
-
// updatedPayload: { ...with generated file keys... },
|
|
297
|
-
// uploadUrls: [{ field, fileKey, uploadUrl, publicUrl }],
|
|
298
|
-
// totalStorageUsed: 67000
|
|
299
|
-
// }
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
#### updateObjectWithPhotos()
|
|
303
|
-
|
|
304
|
-
Updates
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
| `
|
|
363
|
-
|
|
364
|
-
|
|
|
365
|
-
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
1
|
+
# nestjs-r2-storage
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/nestjs-r2-storage)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
**Author:** Nurul Islam Rimon
|
|
7
|
+
**GitHub:** [https://github.com/nurulislamrimon/nestjs-r2-storage](https://github.com/nurulislamrimon/nestjs-r2-storage)
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
Production-ready NestJS module for Cloudflare R2 object storage management.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Signed Upload URLs** - Generate presigned URLs for direct file uploads
|
|
16
|
+
- **Signed Download URLs** - Generate presigned URLs for secure file downloads
|
|
17
|
+
- **File Deletion** - Delete files from R2 storage
|
|
18
|
+
- **Nested Field Support** - Handle paths like `shop.logo`, `profile.avatar`
|
|
19
|
+
- **Array Field Support** - Handle paths like `products[].image`, `gallery[].photo`
|
|
20
|
+
- **Storage Usage Tracking** - Track storage used, increased, and decreased
|
|
21
|
+
- **Full CRUD Lifecycle** - Create, Update, Delete file operations
|
|
22
|
+
- **Access Control Modes** - Control public vs signed URL access (`private`, `public-read`, `hybrid`)
|
|
23
|
+
|
|
24
|
+
## Access Control Modes
|
|
25
|
+
|
|
26
|
+
Cloudflare R2 does NOT enforce ACLs like AWS S3 - the R2 API ignores ACL headers. True security is achieved by controlling URL exposure.
|
|
27
|
+
|
|
28
|
+
### Modes
|
|
29
|
+
|
|
30
|
+
| Mode | Public URLs | Signed URLs | Use Case |
|
|
31
|
+
| ------------- | ----------- | ----------- | ------------------------------------- |
|
|
32
|
+
| `private` | Not allowed | Required | Maximum security - only signed access |
|
|
33
|
+
| `public-read` | Allowed | Optional | Public files (e.g., static assets) |
|
|
34
|
+
| `hybrid` | Allowed | Allowed | Mixed content (default) |
|
|
35
|
+
|
|
36
|
+
### Private Mode
|
|
37
|
+
|
|
38
|
+
Only presigned URLs are allowed. Public URL generation throws `AccessModeError`.
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
R2StorageModule.forRoot({
|
|
42
|
+
// ... other options
|
|
43
|
+
accessMode: "private",
|
|
44
|
+
publicUrlBase: "https://cdn.example.com", // still configured but not used
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Response in private mode:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"uploadUrl": "https://signed-url...",
|
|
53
|
+
"publicUrl": null
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Public-Read Mode
|
|
58
|
+
|
|
59
|
+
Public URLs are generated. Signed URLs are optional.
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
R2StorageModule.forRoot({
|
|
63
|
+
// ... other options
|
|
64
|
+
accessMode: "public-read",
|
|
65
|
+
publicUrlBase: "https://cdn.example.com",
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Hybrid Mode (Default)
|
|
70
|
+
|
|
71
|
+
Both public and signed access are allowed for backward compatibility.
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
R2StorageModule.forRoot({
|
|
75
|
+
// ... other options
|
|
76
|
+
accessMode: "hybrid", // default
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Quick Start
|
|
81
|
+
|
|
82
|
+
### 1. Configure the Module
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// app.module.ts
|
|
86
|
+
import { Module } from "@nestjs/common";
|
|
87
|
+
import { R2StorageModule } from "nestjs-r2-storage";
|
|
88
|
+
|
|
89
|
+
@Module({
|
|
90
|
+
imports: [
|
|
91
|
+
R2StorageModule.forRoot({
|
|
92
|
+
endpoint: process.env.R2_ENDPOINT,
|
|
93
|
+
accessKeyId: process.env.R2_ACCESS_KEY,
|
|
94
|
+
secretAccessKey: process.env.R2_SECRET_KEY,
|
|
95
|
+
bucketName: process.env.R2_BUCKET,
|
|
96
|
+
region: "auto",
|
|
97
|
+
publicUrlBase: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${process.env.R2_BUCKET}`,
|
|
98
|
+
signedUrlExpiry: 3600,
|
|
99
|
+
}),
|
|
100
|
+
],
|
|
101
|
+
})
|
|
102
|
+
export class AppModule {}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 2. Use in Your Service
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
import { Injectable } from "@nestjs/common";
|
|
109
|
+
import {
|
|
110
|
+
PhotoManagerService,
|
|
111
|
+
PhotoField,
|
|
112
|
+
CloudflareService,
|
|
113
|
+
} from "nestjs-r2-storage";
|
|
114
|
+
|
|
115
|
+
@Injectable()
|
|
116
|
+
export class ProductService {
|
|
117
|
+
constructor(
|
|
118
|
+
private readonly photoManager: PhotoManagerService,
|
|
119
|
+
private readonly cloudflare: CloudflareService,
|
|
120
|
+
) {}
|
|
121
|
+
|
|
122
|
+
async createProduct(payload: any) {
|
|
123
|
+
const photoFields: PhotoField[] = [
|
|
124
|
+
{ field: "image", urlField: "image_url", sizeField: "image_size" },
|
|
125
|
+
{
|
|
126
|
+
field: "gallery[].photo",
|
|
127
|
+
urlField: "photo_url",
|
|
128
|
+
sizeField: "photo_size",
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const result = await this.photoManager.createObjectWithPhotos(
|
|
133
|
+
payload,
|
|
134
|
+
photoFields,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Return upload URLs to client for direct upload
|
|
138
|
+
return {
|
|
139
|
+
product: result.updatedPayload,
|
|
140
|
+
uploadUrls: result.uploadUrls,
|
|
141
|
+
totalStorageUsed: result.totalStorageUsed,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async getProduct(id: string) {
|
|
146
|
+
const product = await this.findProduct(id);
|
|
147
|
+
|
|
148
|
+
const photoFields: PhotoField[] = [
|
|
149
|
+
{ field: "image", urlField: "image_url" },
|
|
150
|
+
{ field: "gallery[].photo", urlField: "photo_url" },
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
return this.photoManager.appendPhotoUrls(product, photoFields);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async updateProduct(id: string, payload: any) {
|
|
157
|
+
const existing = await this.findProduct(id);
|
|
158
|
+
|
|
159
|
+
const photoFields: PhotoField[] = [
|
|
160
|
+
{ field: "image", urlField: "image_url", sizeField: "image_size" },
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
const result = await this.photoManager.updateObjectWithPhotos(
|
|
164
|
+
payload,
|
|
165
|
+
existing,
|
|
166
|
+
photoFields,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
product: result.updatedPayload,
|
|
171
|
+
uploadUrls: result.uploadUrls,
|
|
172
|
+
storageIncrease: result.storageIncrease,
|
|
173
|
+
storageDecrease: result.storageDecrease,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async deleteProduct(id: string) {
|
|
178
|
+
const product = await this.findProduct(id);
|
|
179
|
+
|
|
180
|
+
const photoFields: PhotoField[] = [
|
|
181
|
+
{ field: "image", urlField: "image_url" },
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
await this.photoManager.deletePhotosFromObject(product, photoFields);
|
|
185
|
+
await this.removeProduct(id);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## API Reference
|
|
191
|
+
|
|
192
|
+
### CloudflareService
|
|
193
|
+
|
|
194
|
+
Direct R2 operations.
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
// Generate upload URL
|
|
198
|
+
const uploadUrl = await cloudflare.getUploadUrl("avatar.png", 1024000);
|
|
199
|
+
|
|
200
|
+
// Generate download URL
|
|
201
|
+
const downloadUrl = await cloudflare.getDownloadUrl("uploads/avatar_123.png");
|
|
202
|
+
|
|
203
|
+
// Delete file
|
|
204
|
+
await cloudflare.deleteFile("uploads/avatar.png");
|
|
205
|
+
|
|
206
|
+
// Check if file exists
|
|
207
|
+
const exists = await cloudflare.fileExists("uploads/avatar.png");
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Presigned URL Security
|
|
211
|
+
|
|
212
|
+
The module uses secure presigned URL generation:
|
|
213
|
+
|
|
214
|
+
- **Content-Length is NOT signed** - Prevents `SignatureDoesNotMatch` errors (browsers calculate it differently)
|
|
215
|
+
- **Checksum headers disabled** - Uses `requestChecksumCalculation: "WHEN_REQUIRED"` to avoid R2 compatibility issues
|
|
216
|
+
- **Minimal signing** - Only signs `host` and `content-type` headers
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
const result = await cloudflare.getUploadUrl("avatar.png", 1024000);
|
|
220
|
+
|
|
221
|
+
// result = {
|
|
222
|
+
// uploadUrl: "https://signed-url...",
|
|
223
|
+
// fileKey: "uploads/avatar_123.png",
|
|
224
|
+
// publicUrl: "https://cdn.example.com/uploads/avatar_123.png",
|
|
225
|
+
// mimeType: "image/png",
|
|
226
|
+
// sizeField: 1024000 // Use this for client-side validation before upload
|
|
227
|
+
// }
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### PhotoManagerService
|
|
231
|
+
|
|
232
|
+
High-level photo management.
|
|
233
|
+
|
|
234
|
+
#### appendPhotoUrls()
|
|
235
|
+
|
|
236
|
+
Adds signed URLs to response objects.
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
const photoFields: PhotoField[] = [
|
|
240
|
+
{ field: "avatar", urlField: "avatar_url" },
|
|
241
|
+
{ field: "shop.logo", urlField: "logo_url" },
|
|
242
|
+
{ field: "products[].image", urlField: "image_url" },
|
|
243
|
+
{ field: "gallery[].photo", urlField: "photo_url" },
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
const result = await photoManager.appendPhotoUrls(product, photoFields);
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Input:
|
|
250
|
+
|
|
251
|
+
```json
|
|
252
|
+
{
|
|
253
|
+
"name": "Laptop",
|
|
254
|
+
"image": "laptop.png",
|
|
255
|
+
"gallery": [{ "photo": "photo1.jpg" }, { "photo": "photo2.jpg" }]
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Output:
|
|
260
|
+
|
|
261
|
+
```json
|
|
262
|
+
{
|
|
263
|
+
"name": "Laptop",
|
|
264
|
+
"image": "laptop.png",
|
|
265
|
+
"image_url": "https://signed-url...",
|
|
266
|
+
"gallery": [
|
|
267
|
+
{ "photo": "photo1.jpg", "photo_url": "https://signed-url..." },
|
|
268
|
+
{ "photo": "photo2.jpg", "photo_url": "https://signed-url..." }
|
|
269
|
+
]
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
#### createObjectWithPhotos()
|
|
274
|
+
|
|
275
|
+
Creates object with photo upload URLs.
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
const payload = {
|
|
279
|
+
name: "Laptop",
|
|
280
|
+
image: "laptop.png",
|
|
281
|
+
image_size: 42000,
|
|
282
|
+
gallery: [
|
|
283
|
+
{ photo: "photo1.jpg", photo_size: 10000 },
|
|
284
|
+
{ photo: "photo2.jpg", photo_size: 15000 },
|
|
285
|
+
],
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const photoFields: PhotoField[] = [
|
|
289
|
+
{ field: "image", sizeField: "image_size" },
|
|
290
|
+
{ field: "gallery[].photo", sizeField: "gallery[].photo_size" },
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
const result = await photoManager.createObjectWithPhotos(payload, photoFields);
|
|
294
|
+
|
|
295
|
+
// result = {
|
|
296
|
+
// updatedPayload: { ...with generated file keys... },
|
|
297
|
+
// uploadUrls: [{ field, fileKey, uploadUrl, publicUrl }],
|
|
298
|
+
// totalStorageUsed: 67000
|
|
299
|
+
// }
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
#### updateObjectWithPhotos()
|
|
303
|
+
|
|
304
|
+
Updates object with new photos, deletes old files.
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
const result = await photoManager.updateObjectWithPhotos(
|
|
308
|
+
newPayload,
|
|
309
|
+
existingObject,
|
|
310
|
+
photoFields,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// result = {
|
|
314
|
+
// updatedPayload: { ... },
|
|
315
|
+
// uploadUrls: [{ field, fileKey, uploadUrl, publicUrl }],
|
|
316
|
+
// storageIncrease: 1000,
|
|
317
|
+
// storageDecrease: 500,
|
|
318
|
+
// deletedFiles: ['old-file.png']
|
|
319
|
+
// }
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
#### deletePhotosFromObject()
|
|
323
|
+
|
|
324
|
+
Deletes all photos from object.
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
const result = await photoManager.deletePhotosFromObject(product, photoFields);
|
|
328
|
+
|
|
329
|
+
// result = {
|
|
330
|
+
// deletedFiles: ['file1.png', 'file2.jpg'],
|
|
331
|
+
// totalStorageFreed: 25000
|
|
332
|
+
// }
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
## Upload Detection (Update Operations)
|
|
336
|
+
|
|
337
|
+
The `updateObjectWithPhotos()` method uses intelligent upload detection to prevent unnecessary uploads and file deletions.
|
|
338
|
+
|
|
339
|
+
### Dual-Mode Detection
|
|
340
|
+
|
|
341
|
+
**Mode 1: Explicit Size Detection (when `sizeField` is defined)**
|
|
342
|
+
- Only treats a field as a new upload if `sizeField > 0`
|
|
343
|
+
- If `sizeField` is missing, null, or 0 → treats as existing file (no upload)
|
|
344
|
+
|
|
345
|
+
**Mode 2: Filename Comparison (when `sizeField` is NOT defined)**
|
|
346
|
+
- Compares base filenames between existing fileKey and incoming filename
|
|
347
|
+
- Strips path, extension, and trailing timestamps before comparing
|
|
348
|
+
- Only uploads if the base name differs
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
// Example: existing fileKey = "product_1776949996526.png"
|
|
352
|
+
// Incoming filename = "product.png"
|
|
353
|
+
// extractBaseFilename returns "product" for both → no upload
|
|
354
|
+
|
|
355
|
+
// Example: existing fileKey = "product_1776949996526.png"
|
|
356
|
+
// Incoming filename = "new-product.png"
|
|
357
|
+
// extractBaseFilename returns "product" vs "new-product" → upload
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### Detection Matrix
|
|
361
|
+
|
|
362
|
+
| Scenario | `sizeField` defined | Result |
|
|
363
|
+
|----------|---------------------|--------|
|
|
364
|
+
| Existing file unchanged | yes (size=0) | No upload, no delete |
|
|
365
|
+
| Existing file unchanged | no (same base) | No upload, no delete |
|
|
366
|
+
| Same filename resent | yes (size=0) | No upload |
|
|
367
|
+
| Same filename resent | no (same base) | No upload |
|
|
368
|
+
| New file uploaded | yes (size > 0) | Upload + replace old |
|
|
369
|
+
| New file (no sizeField) | no (different base) | Upload + replace old |
|
|
370
|
+
|
|
371
|
+
### Backward Compatibility
|
|
372
|
+
|
|
373
|
+
- Existing clients that send `sizeField > 0` continue to work as before
|
|
374
|
+
- Clients that don't send `sizeField` get automatic fallback to filename comparison
|
|
375
|
+
- No changes to DTO structure or API required
|
|
376
|
+
|
|
377
|
+
## Field Path Syntax
|
|
378
|
+
|
|
379
|
+
### Simple Nested Fields
|
|
380
|
+
|
|
381
|
+
```
|
|
382
|
+
shop.logo
|
|
383
|
+
profile.avatar
|
|
384
|
+
user.profile.image
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Array Fields
|
|
388
|
+
|
|
389
|
+
```
|
|
390
|
+
gallery[].photo -> gallery[0].photo, gallery[1].photo, ...
|
|
391
|
+
products[].image -> products[0].image, products[1].image, ...
|
|
392
|
+
variants[].images[] -> variants[0].images[0], variants[0].images[1], ...
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Supported Patterns
|
|
396
|
+
|
|
397
|
+
| Path | Description |
|
|
398
|
+
| -------------------------- | ------------------------------- |
|
|
399
|
+
| `shop.logo` | Simple nested field |
|
|
400
|
+
| `user.profile.image` | Deeply nested with dots |
|
|
401
|
+
| `gallery[].photo` | Array of objects |
|
|
402
|
+
| `products[].images[]` | Array containing array |
|
|
403
|
+
| `variants[0].images[].url` | Indexed array with nested array |
|
|
404
|
+
|
|
405
|
+
## Configuration Options
|
|
406
|
+
|
|
407
|
+
| Option | Type | Required | Description |
|
|
408
|
+
| ----------------- | ------ | -------- | ------------------------------------------------------------------- |
|
|
409
|
+
| `endpoint` | string | Yes | R2 endpoint URL |
|
|
410
|
+
| `accessKeyId` | string | Yes | R2 access key ID |
|
|
411
|
+
| `secretAccessKey` | string | Yes | R2 secret access key |
|
|
412
|
+
| `bucketName` | string | Yes | R2 bucket name |
|
|
413
|
+
| `region` | string | No | AWS region (default: 'auto') |
|
|
414
|
+
| `publicUrlBase` | string | No | Base URL for public access |
|
|
415
|
+
| `signedUrlExpiry` | number | No | Signed URL expiry in seconds (default: 3600) |
|
|
416
|
+
| `accessMode` | string | No | Access mode: `private`, `public-read`, `hybrid` (default: `hybrid`) |
|
|
417
|
+
|
|
418
|
+
## Error Handling
|
|
419
|
+
|
|
420
|
+
### AccessModeError
|
|
421
|
+
|
|
422
|
+
Thrown when attempting to generate public URLs in `private` access mode.
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
import { AccessModeError } from "nestjs-r2-storage";
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
const result = await cloudflare.getUploadUrl("file.png", 1024);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (error instanceof AccessModeError) {
|
|
431
|
+
console.log(error.message); // "Public URL generation is not allowed in 'private' access mode..."
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
## Async Configuration
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
R2StorageModule.forRootAsync({
|
|
440
|
+
useFactory: () => ({
|
|
441
|
+
endpoint: process.env.R2_ENDPOINT,
|
|
442
|
+
accessKeyId: process.env.R2_ACCESS_KEY,
|
|
443
|
+
secretAccessKey: process.env.R2_SECRET_KEY,
|
|
444
|
+
bucketName: process.env.R2_BUCKET,
|
|
445
|
+
}),
|
|
446
|
+
});
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
## Changelog
|
|
450
|
+
|
|
451
|
+
### v1.4.0 (2026-04-23)
|
|
452
|
+
|
|
453
|
+
- **Dual-mode upload detection** for `updateObjectWithPhotos()`:
|
|
454
|
+
- Mode 1: Explicit size detection when `sizeField` is defined (size > 0 = new upload)
|
|
455
|
+
- Mode 2: Base filename comparison when `sizeField` is NOT defined
|
|
456
|
+
- **Deterministic file replacement** using `extractBaseFilename()` helper
|
|
457
|
+
- **Backward compatible**: Existing clients work unchanged, clients without `sizeField` get automatic fallback
|
|
458
|
+
- Removed heuristic-based upload detection (no longer relies on filename patterns or "/" presence)
|
|
459
|
+
|
|
460
|
+
### v1.3.4 (2026-04-23)
|
|
461
|
+
|
|
462
|
+
- Refactored getNestedValue: access key first, then handle array segments
|
|
463
|
+
- Refactored setNestedValue: proper handling of empty brackets [] and indexed arrays [0]
|
|
464
|
+
- Robust parsing of paths: user.profile.image, gallery[].photo, variants[0].images[].url
|
|
465
|
+
|
|
466
|
+
### v1.2.5 (2025-04-20)
|
|
467
|
+
|
|
468
|
+
- Rewrote parseFieldPath to split by dot then parse each segment (fixes regex state bugs)
|
|
469
|
+
- Fixed getNestedValue array traversal when next key is a property (not array/index)
|
|
470
|
+
- Fixed setNestedValue for empty array brackets `[]` and indexed arrays `[0]`
|
|
471
|
+
- Added null/undefined guards throughout
|
|
472
|
+
- Supports: `gallery[].photo`, `variants[].images[].url`, `a[].b[0].c`
|
|
473
|
+
|
|
474
|
+
### v1.2.4 (2025-04-20)
|
|
475
|
+
|
|
476
|
+
- Fixed parseFieldPath regex to handle keys containing dots
|
|
477
|
+
- Fixed parseFieldPath empty bracket handling (`[]` now correctly returns `undefined` for arrayIndex)
|
|
478
|
+
- Fixed getNestedValue array traversal for paths like `variants[].photo`
|
|
479
|
+
- Added null/undefined guards in array field processing methods
|
|
480
|
+
- Improved safety for deeply nested array structures
|
|
481
|
+
|
|
482
|
+
### v1.2.3 (2025-04-13)
|
|
483
|
+
|
|
484
|
+
- Added AccessModeError for private mode public URL generation
|
|
485
|
+
|
|
486
|
+
## License
|
|
487
|
+
|
|
488
|
+
MIT
|