nestjs-r2-storage 1.5.1 → 1.6.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 +532 -448
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -3
- package/dist/index.js.map +1 -1
- package/dist/r2-storage/cloudflare.service.js +217 -217
- package/dist/r2-storage/photo-manager.service.js +15 -9
- package/dist/r2-storage/photo-manager.service.js.map +1 -1
- package/dist/r2-storage/r2-storage.module.js +53 -53
- package/dist/r2-storage/utils/nested-value.util.d.ts +1 -2
- package/dist/r2-storage/utils/nested-value.util.js +26 -24
- package/dist/r2-storage/utils/nested-value.util.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +45 -45
package/README.md
CHANGED
|
@@ -1,448 +1,532 @@
|
|
|
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
|
-
- **
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
|
32
|
-
|
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
import {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
import {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
private readonly
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
{
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
{ field: "
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
await this.
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
+
- **Nested Array Paths** - Handle paths like `order_items[].product.photo_1`
|
|
21
|
+
- **Storage Usage Tracking** - Track storage used, increased, and decreased
|
|
22
|
+
- **Full CRUD Lifecycle** - Create, Update, Delete file operations
|
|
23
|
+
- **Access Control Modes** - Control public vs signed URL access (`private`, `public-read`, `hybrid`)
|
|
24
|
+
|
|
25
|
+
## Access Control Modes
|
|
26
|
+
|
|
27
|
+
Cloudflare R2 does NOT enforce ACLs like AWS S3 - the R2 API ignores ACL headers. True security is achieved by controlling URL exposure.
|
|
28
|
+
|
|
29
|
+
### Modes
|
|
30
|
+
|
|
31
|
+
| Mode | Public URLs | Signed URLs | Use Case |
|
|
32
|
+
| ------------- | ----------- | ----------- | ------------------------------------- |
|
|
33
|
+
| `private` | Not allowed | Required | Maximum security - only signed access |
|
|
34
|
+
| `public-read` | Allowed | Optional | Public files (e.g., static assets) |
|
|
35
|
+
| `hybrid` | Allowed | Allowed | Mixed content (default) |
|
|
36
|
+
|
|
37
|
+
### Private Mode
|
|
38
|
+
|
|
39
|
+
Only presigned URLs are allowed. Public URL generation throws `AccessModeError`.
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
R2StorageModule.forRoot({
|
|
43
|
+
// ... other options
|
|
44
|
+
accessMode: "private",
|
|
45
|
+
publicUrlBase: "https://cdn.example.com", // still configured but not used
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Response in private mode:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"uploadUrl": "https://signed-url...",
|
|
54
|
+
"publicUrl": null
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Public-Read Mode
|
|
59
|
+
|
|
60
|
+
Public URLs are generated. Signed URLs are optional.
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
R2StorageModule.forRoot({
|
|
64
|
+
// ... other options
|
|
65
|
+
accessMode: "public-read",
|
|
66
|
+
publicUrlBase: "https://cdn.example.com",
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Hybrid Mode (Default)
|
|
71
|
+
|
|
72
|
+
Both public and signed access are allowed for backward compatibility.
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
R2StorageModule.forRoot({
|
|
76
|
+
// ... other options
|
|
77
|
+
accessMode: "hybrid", // default
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Quick Start
|
|
82
|
+
|
|
83
|
+
### 1. Configure the Module
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
// app.module.ts
|
|
87
|
+
import { Module } from "@nestjs/common";
|
|
88
|
+
import { R2StorageModule } from "nestjs-r2-storage";
|
|
89
|
+
|
|
90
|
+
@Module({
|
|
91
|
+
imports: [
|
|
92
|
+
R2StorageModule.forRoot({
|
|
93
|
+
endpoint: process.env.R2_ENDPOINT,
|
|
94
|
+
accessKeyId: process.env.R2_ACCESS_KEY,
|
|
95
|
+
secretAccessKey: process.env.R2_SECRET_KEY,
|
|
96
|
+
bucketName: process.env.R2_BUCKET,
|
|
97
|
+
region: "auto",
|
|
98
|
+
publicUrlBase: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${process.env.R2_BUCKET}`,
|
|
99
|
+
signedUrlExpiry: 3600,
|
|
100
|
+
}),
|
|
101
|
+
],
|
|
102
|
+
})
|
|
103
|
+
export class AppModule {}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### 2. Use in Your Service
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import { Injectable } from "@nestjs/common";
|
|
110
|
+
import {
|
|
111
|
+
PhotoManagerService,
|
|
112
|
+
PhotoField,
|
|
113
|
+
CloudflareService,
|
|
114
|
+
} from "nestjs-r2-storage";
|
|
115
|
+
|
|
116
|
+
@Injectable()
|
|
117
|
+
export class ProductService {
|
|
118
|
+
constructor(
|
|
119
|
+
private readonly photoManager: PhotoManagerService,
|
|
120
|
+
private readonly cloudflare: CloudflareService,
|
|
121
|
+
) {}
|
|
122
|
+
|
|
123
|
+
async createProduct(payload: any) {
|
|
124
|
+
const photoFields: PhotoField[] = [
|
|
125
|
+
{ field: "image", urlField: "image_url", sizeField: "image_size" },
|
|
126
|
+
{
|
|
127
|
+
field: "gallery[].photo",
|
|
128
|
+
urlField: "photo_url",
|
|
129
|
+
sizeField: "photo_size",
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
const result = await this.photoManager.createObjectWithPhotos(
|
|
134
|
+
payload,
|
|
135
|
+
photoFields,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Return upload URLs to client for direct upload
|
|
139
|
+
return {
|
|
140
|
+
product: result.updatedPayload,
|
|
141
|
+
uploadUrls: result.uploadUrls,
|
|
142
|
+
totalStorageUsed: result.totalStorageUsed,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async getProduct(id: string) {
|
|
147
|
+
const product = await this.findProduct(id);
|
|
148
|
+
|
|
149
|
+
const photoFields: PhotoField[] = [
|
|
150
|
+
{ field: "image", urlField: "image_url" },
|
|
151
|
+
{ field: "gallery[].photo", urlField: "photo_url" },
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
return this.photoManager.appendPhotoUrls(product, photoFields);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async updateProduct(id: string, payload: any) {
|
|
158
|
+
const existing = await this.findProduct(id);
|
|
159
|
+
|
|
160
|
+
const photoFields: PhotoField[] = [
|
|
161
|
+
{ field: "image", urlField: "image_url", sizeField: "image_size" },
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
const result = await this.photoManager.updateObjectWithPhotos(
|
|
165
|
+
payload,
|
|
166
|
+
existing,
|
|
167
|
+
photoFields,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
product: result.updatedPayload,
|
|
172
|
+
uploadUrls: result.uploadUrls,
|
|
173
|
+
storageIncrease: result.storageIncrease,
|
|
174
|
+
storageDecrease: result.storageDecrease,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async deleteProduct(id: string) {
|
|
179
|
+
const product = await this.findProduct(id);
|
|
180
|
+
|
|
181
|
+
const photoFields: PhotoField[] = [
|
|
182
|
+
{ field: "image", urlField: "image_url" },
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
await this.photoManager.deletePhotosFromObject(product, photoFields);
|
|
186
|
+
await this.removeProduct(id);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Nested Array Paths
|
|
192
|
+
|
|
193
|
+
The package supports accessing nested properties within array items using paths like `order_items[].product.photo_1`.
|
|
194
|
+
|
|
195
|
+
### Example: E-commerce Order with Product Photos
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
const order = {
|
|
199
|
+
id: "order_123",
|
|
200
|
+
order_items: [
|
|
201
|
+
{
|
|
202
|
+
product: {
|
|
203
|
+
id: "prod_1",
|
|
204
|
+
name: "Laptop",
|
|
205
|
+
photo_1: "laptop.png",
|
|
206
|
+
photo_1_size: 50000,
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
product: {
|
|
211
|
+
id: "prod_2",
|
|
212
|
+
name: "Mouse",
|
|
213
|
+
photo_1: "mouse.png",
|
|
214
|
+
photo_1_size: 10000,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const photoFields: PhotoField[] = [
|
|
221
|
+
{
|
|
222
|
+
field: "order_items[].product.photo_1",
|
|
223
|
+
sizeField: "order_items[].product.photo_1_size",
|
|
224
|
+
urlField: "order_items[].product.photo_1_url",
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
// Generate signed URLs for all product photos
|
|
229
|
+
const result = await photoManager.appendPhotoUrls(order, photoFields);
|
|
230
|
+
|
|
231
|
+
// Result:
|
|
232
|
+
// {
|
|
233
|
+
// id: "order_123",
|
|
234
|
+
// order_items: [
|
|
235
|
+
// {
|
|
236
|
+
// product: {
|
|
237
|
+
// id: "prod_1",
|
|
238
|
+
// name: "Laptop",
|
|
239
|
+
// photo_1: "laptop.png",
|
|
240
|
+
// photo_1_size: 50000,
|
|
241
|
+
// photo_1_url: "https://signed-url-for-laptop..."
|
|
242
|
+
// }
|
|
243
|
+
// },
|
|
244
|
+
// {
|
|
245
|
+
// product: {
|
|
246
|
+
// id: "prod_2",
|
|
247
|
+
// name: "Mouse",
|
|
248
|
+
// photo_1: "mouse.png",
|
|
249
|
+
// photo_1_size: 10000,
|
|
250
|
+
// photo_1_url: "https://signed-url-for-mouse..."
|
|
251
|
+
// }
|
|
252
|
+
// }
|
|
253
|
+
// ]
|
|
254
|
+
// }
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## API Reference
|
|
258
|
+
|
|
259
|
+
### CloudflareService
|
|
260
|
+
|
|
261
|
+
Direct R2 operations.
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
// Generate upload URL
|
|
265
|
+
const uploadUrl = await cloudflare.getUploadUrl("avatar.png", 1024000);
|
|
266
|
+
|
|
267
|
+
// Generate download URL
|
|
268
|
+
const downloadUrl = await cloudflare.getDownloadUrl("uploads/avatar_123.png");
|
|
269
|
+
|
|
270
|
+
// Delete file
|
|
271
|
+
await cloudflare.deleteFile("uploads/avatar.png");
|
|
272
|
+
|
|
273
|
+
// Check if file exists
|
|
274
|
+
const exists = await cloudflare.fileExists("uploads/avatar.png");
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Presigned URL Security
|
|
278
|
+
|
|
279
|
+
The module uses secure presigned URL generation:
|
|
280
|
+
|
|
281
|
+
- **Content-Length is NOT signed** - Prevents `SignatureDoesNotMatch` errors (browsers calculate it differently)
|
|
282
|
+
- **Checksum headers disabled** - Uses `requestChecksumCalculation: "WHEN_REQUIRED"` to avoid R2 compatibility issues
|
|
283
|
+
- **Minimal signing** - Only signs `host` and `content-type` headers
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
const result = await cloudflare.getUploadUrl("avatar.png", 1024000);
|
|
287
|
+
|
|
288
|
+
// result = {
|
|
289
|
+
// uploadUrl: "https://signed-url...",
|
|
290
|
+
// fileKey: "uploads/avatar_123.png",
|
|
291
|
+
// publicUrl: "https://cdn.example.com/uploads/avatar_123.png",
|
|
292
|
+
// mimeType: "image/png",
|
|
293
|
+
// sizeField: 1024000 // Use this for client-side validation before upload
|
|
294
|
+
// }
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### PhotoManagerService
|
|
298
|
+
|
|
299
|
+
High-level photo management.
|
|
300
|
+
|
|
301
|
+
#### appendPhotoUrls()
|
|
302
|
+
|
|
303
|
+
Adds signed URLs to response objects.
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
const photoFields: PhotoField[] = [
|
|
307
|
+
{ field: "avatar", urlField: "avatar_url" },
|
|
308
|
+
{ field: "shop.logo", urlField: "logo_url" },
|
|
309
|
+
{ field: "products[].image", urlField: "image_url" },
|
|
310
|
+
{ field: "gallery[].photo", urlField: "photo_url" },
|
|
311
|
+
{ field: "order_items[].product.photo_1", urlField: "photo_1_url" },
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
const result = await photoManager.appendPhotoUrls(product, photoFields);
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Input:
|
|
318
|
+
|
|
319
|
+
```json
|
|
320
|
+
{
|
|
321
|
+
"name": "Laptop",
|
|
322
|
+
"image": "laptop.png",
|
|
323
|
+
"gallery": [{ "photo": "photo1.jpg" }, { "photo": "photo2.jpg" }]
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Output:
|
|
328
|
+
|
|
329
|
+
```json
|
|
330
|
+
{
|
|
331
|
+
"name": "Laptop",
|
|
332
|
+
"image": "laptop.png",
|
|
333
|
+
"image_url": "https://signed-url...",
|
|
334
|
+
"gallery": [
|
|
335
|
+
{ "photo": "photo1.jpg", "photo_url": "https://signed-url..." },
|
|
336
|
+
{ "photo": "photo2.jpg", "photo_url": "https://signed-url..." }
|
|
337
|
+
]
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
#### createObjectWithPhotos()
|
|
342
|
+
|
|
343
|
+
Creates object with photo upload URLs.
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
const payload = {
|
|
347
|
+
name: "Laptop",
|
|
348
|
+
image: "laptop.png",
|
|
349
|
+
image_size: 42000,
|
|
350
|
+
gallery: [
|
|
351
|
+
{ photo: "photo1.jpg", photo_size: 10000 },
|
|
352
|
+
{ photo: "photo2.jpg", photo_size: 15000 },
|
|
353
|
+
],
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const photoFields: PhotoField[] = [
|
|
357
|
+
{ field: "image", sizeField: "image_size" },
|
|
358
|
+
{ field: "gallery[].photo", sizeField: "gallery[].photo_size" },
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
const result = await photoManager.createObjectWithPhotos(payload, photoFields);
|
|
362
|
+
|
|
363
|
+
// result = {
|
|
364
|
+
// updatedPayload: { ...with generated file keys... },
|
|
365
|
+
// uploadUrls: [{ field, fileKey, uploadUrl, publicUrl }],
|
|
366
|
+
// totalStorageUsed: 67000
|
|
367
|
+
// }
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
#### updateObjectWithPhotos()
|
|
371
|
+
|
|
372
|
+
Updates object with new photos, deletes old files.
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
const result = await photoManager.updateObjectWithPhotos(
|
|
376
|
+
newPayload,
|
|
377
|
+
existingObject,
|
|
378
|
+
photoFields,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// result = {
|
|
382
|
+
// updatedPayload: { ... },
|
|
383
|
+
// uploadUrls: [{ field, fileKey, uploadUrl, publicUrl }],
|
|
384
|
+
// storageIncrease: 1000,
|
|
385
|
+
// storageDecrease: 500,
|
|
386
|
+
// deletedFiles: ['old-file.png']
|
|
387
|
+
// }
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
#### deletePhotosFromObject()
|
|
391
|
+
|
|
392
|
+
Deletes all photos from object.
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
const result = await photoManager.deletePhotosFromObject(product, photoFields);
|
|
396
|
+
|
|
397
|
+
// result = {
|
|
398
|
+
// deletedFiles: ['file1.png', 'file2.jpg'],
|
|
399
|
+
// totalStorageFreed: 25000
|
|
400
|
+
// }
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Field Path Syntax
|
|
404
|
+
|
|
405
|
+
### Simple Nested Fields
|
|
406
|
+
|
|
407
|
+
```
|
|
408
|
+
shop.logo
|
|
409
|
+
profile.avatar
|
|
410
|
+
user.profile.image
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### Array Fields
|
|
414
|
+
|
|
415
|
+
```
|
|
416
|
+
gallery[].photo -> gallery[0].photo, gallery[1].photo, ...
|
|
417
|
+
products[].image -> products[0].image, products[1].image, ...
|
|
418
|
+
variants[].images[] -> variants[0].images[0], variants[0].images[1], ...
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### Nested Array Paths
|
|
422
|
+
|
|
423
|
+
```
|
|
424
|
+
order_items[].product.photo_1 -> Access photo_1 inside product inside each order item
|
|
425
|
+
users[].profile.avatar -> Access avatar inside profile inside each user
|
|
426
|
+
categories[].items[].image -> Deeply nested arrays with properties
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### Supported Patterns
|
|
430
|
+
|
|
431
|
+
| Path | Description |
|
|
432
|
+
| -------------------------- | ------------------------------- |
|
|
433
|
+
| `shop.logo` | Simple nested field |
|
|
434
|
+
| `user.profile.image` | Deeply nested with dots |
|
|
435
|
+
| `gallery[].photo` | Array of objects |
|
|
436
|
+
| `products[].images[]` | Array containing array |
|
|
437
|
+
| `variants[0].images[].url` | Indexed array with nested array |
|
|
438
|
+
| `order_items[].product.photo_1` | Nested property in array items |
|
|
439
|
+
|
|
440
|
+
## Configuration Options
|
|
441
|
+
|
|
442
|
+
| Option | Type | Required | Description |
|
|
443
|
+
| ----------------- | ------ | -------- | ------------------------------------------------------------------- |
|
|
444
|
+
| `endpoint` | string | Yes | R2 endpoint URL |
|
|
445
|
+
| `accessKeyId` | string | Yes | R2 access key ID |
|
|
446
|
+
| `secretAccessKey` | string | Yes | R2 secret access key |
|
|
447
|
+
| `bucketName` | string | Yes | R2 bucket name |
|
|
448
|
+
| `region` | string | No | AWS region (default: 'auto') |
|
|
449
|
+
| `publicUrlBase` | string | No | Base URL for public access |
|
|
450
|
+
| `signedUrlExpiry` | number | No | Signed URL expiry in seconds (default: 3600) |
|
|
451
|
+
| `accessMode` | string | No | Access mode: `private`, `public-read`, `hybrid` (default: `hybrid`) |
|
|
452
|
+
|
|
453
|
+
## Error Handling
|
|
454
|
+
|
|
455
|
+
### AccessModeError
|
|
456
|
+
|
|
457
|
+
Thrown when attempting to generate public URLs in `private` access mode.
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
import { AccessModeError } from "nestjs-r2-storage";
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
const result = await cloudflare.getUploadUrl("file.png", 1024);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
if (error instanceof AccessModeError) {
|
|
466
|
+
console.log(error.message); // "Public URL generation is not allowed in 'private' access mode..."
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
## Async Configuration
|
|
472
|
+
|
|
473
|
+
```typescript
|
|
474
|
+
R2StorageModule.forRootAsync({
|
|
475
|
+
useFactory: () => ({
|
|
476
|
+
endpoint: process.env.R2_ENDPOINT,
|
|
477
|
+
accessKeyId: process.env.R2_ACCESS_KEY,
|
|
478
|
+
secretAccessKey: process.env.R2_SECRET_KEY,
|
|
479
|
+
bucketName: process.env.R2_BUCKET,
|
|
480
|
+
}),
|
|
481
|
+
});
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
## Changelog
|
|
485
|
+
|
|
486
|
+
### v1.6.0 (2026-05-03)
|
|
487
|
+
|
|
488
|
+
- **Nested array path support** - Handle paths like `order_items[].product.photo_1`
|
|
489
|
+
- **Improved path parsing** - Better handling of nested properties after array notation
|
|
490
|
+
- **New utility function** - Added `getSubPathAfterArray()` to extract sub-paths after array segments
|
|
491
|
+
- **Updated exports** - Replaced `getArrayBasePath` and `getArrayElementPath` with `getSubPathAfterArray`
|
|
492
|
+
|
|
493
|
+
### v1.5.0 (2026-04-25)
|
|
494
|
+
|
|
495
|
+
- **Refactored photo update logic** - Diff-based (state reconciliation) instead of request-driven
|
|
496
|
+
- **No unnecessary uploads** - Files are only uploaded when the value actually changes
|
|
497
|
+
- **No accidental deletions** - Files are only deleted when removed from the payload
|
|
498
|
+
- **Index-based array handling** - Array fields use path-based comparison (`gallery[0].photo`) instead of filename matching
|
|
499
|
+
- **Reusable `extractExistingFileMap()`** - Public method for extracting `fieldPath → fileKey` maps
|
|
500
|
+
- **Optimized traversal** - Single-pass map extraction, O(1) lookups via Map
|
|
501
|
+
- **Size is optional** - Size field does not affect diff logic, only used for storage tracking
|
|
502
|
+
- **Deterministic behavior** - Backend-driven, no assumptions about frontend behavior
|
|
503
|
+
|
|
504
|
+
### v1.2.6 (2025-04-20)
|
|
505
|
+
|
|
506
|
+
- Refactored getNestedValue: access key first, then handle array segments
|
|
507
|
+
- Refactored setNestedValue: proper handling of empty brackets [] and indexed arrays [0]
|
|
508
|
+
- Robust parsing of paths: user.profile.image, gallery[].photo, variants[0].images[].url
|
|
509
|
+
|
|
510
|
+
### v1.2.5 (2025-04-20)
|
|
511
|
+
|
|
512
|
+
- Rewrote parseFieldPath to split by dot then parse each segment (fixes regex state bugs)
|
|
513
|
+
- Fixed getNestedValue array traversal when next key is a property (not array/index)
|
|
514
|
+
- Fixed setNestedValue for empty array brackets `[]` and indexed arrays `[0]`
|
|
515
|
+
- Added null/undefined guards throughout
|
|
516
|
+
- Supports: `gallery[].photo`, `variants[].images[].url`, `a[].b[0].c`
|
|
517
|
+
|
|
518
|
+
### v1.2.4 (2025-04-20)
|
|
519
|
+
|
|
520
|
+
- Fixed parseFieldPath regex to handle keys containing dots
|
|
521
|
+
- Fixed parseFieldPath empty bracket handling (`[]` now correctly returns `undefined` for arrayIndex)
|
|
522
|
+
- Fixed getNestedValue array traversal for paths like `variants[].photo`
|
|
523
|
+
- Added null/undefined guards in array field processing methods
|
|
524
|
+
- Improved safety for deeply nested array structures
|
|
525
|
+
|
|
526
|
+
### v1.2.3 (2025-04-13)
|
|
527
|
+
|
|
528
|
+
- Added AccessModeError for private mode public URL generation
|
|
529
|
+
|
|
530
|
+
## License
|
|
531
|
+
|
|
532
|
+
MIT
|