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 CHANGED
@@ -1,448 +1,532 @@
1
- # nestjs-r2-storage
2
-
3
- [![npm version](https://img.shields.io/npm/v/nestjs-r2-storage.svg)](https://www.npmjs.com/package/nestjs-r2-storage)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
- ![Nestjs R2 Storage](nestjs-r2-storage.gif)
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
- ## Field Path Syntax
336
-
337
- ### Simple Nested Fields
338
-
339
- ```
340
- shop.logo
341
- profile.avatar
342
- user.profile.image
343
- ```
344
-
345
- ### Array Fields
346
-
347
- ```
348
- gallery[].photo -> gallery[0].photo, gallery[1].photo, ...
349
- products[].image -> products[0].image, products[1].image, ...
350
- variants[].images[] -> variants[0].images[0], variants[0].images[1], ...
351
- ```
352
-
353
- ### Supported Patterns
354
-
355
- | Path | Description |
356
- | -------------------------- | ------------------------------- |
357
- | `shop.logo` | Simple nested field |
358
- | `user.profile.image` | Deeply nested with dots |
359
- | `gallery[].photo` | Array of objects |
360
- | `products[].images[]` | Array containing array |
361
- | `variants[0].images[].url` | Indexed array with nested array |
362
-
363
- ## Configuration Options
364
-
365
- | Option | Type | Required | Description |
366
- | ----------------- | ------ | -------- | ------------------------------------------------------------------- |
367
- | `endpoint` | string | Yes | R2 endpoint URL |
368
- | `accessKeyId` | string | Yes | R2 access key ID |
369
- | `secretAccessKey` | string | Yes | R2 secret access key |
370
- | `bucketName` | string | Yes | R2 bucket name |
371
- | `region` | string | No | AWS region (default: 'auto') |
372
- | `publicUrlBase` | string | No | Base URL for public access |
373
- | `signedUrlExpiry` | number | No | Signed URL expiry in seconds (default: 3600) |
374
- | `accessMode` | string | No | Access mode: `private`, `public-read`, `hybrid` (default: `hybrid`) |
375
-
376
- ## Error Handling
377
-
378
- ### AccessModeError
379
-
380
- Thrown when attempting to generate public URLs in `private` access mode.
381
-
382
- ```typescript
383
- import { AccessModeError } from "nestjs-r2-storage";
384
-
385
- try {
386
- const result = await cloudflare.getUploadUrl("file.png", 1024);
387
- } catch (error) {
388
- if (error instanceof AccessModeError) {
389
- console.log(error.message); // "Public URL generation is not allowed in 'private' access mode..."
390
- }
391
- }
392
- ```
393
-
394
- ## Async Configuration
395
-
396
- ```typescript
397
- R2StorageModule.forRootAsync({
398
- useFactory: () => ({
399
- endpoint: process.env.R2_ENDPOINT,
400
- accessKeyId: process.env.R2_ACCESS_KEY,
401
- secretAccessKey: process.env.R2_SECRET_KEY,
402
- bucketName: process.env.R2_BUCKET,
403
- }),
404
- });
405
- ```
406
-
407
- ## Changelog
408
-
409
- ### v1.5.0 (2026-04-25)
410
-
411
- - **Refactored photo update logic** - Diff-based (state reconciliation) instead of request-driven
412
- - **No unnecessary uploads** - Files are only uploaded when the value actually changes
413
- - **No accidental deletions** - Files are only deleted when removed from the payload
414
- - **Index-based array handling** - Array fields use path-based comparison (`gallery[0].photo`) instead of filename matching
415
- - **Reusable `extractExistingFileMap()`** - Public method for extracting `fieldPath → fileKey` maps
416
- - **Optimized traversal** - Single-pass map extraction, O(1) lookups via Map
417
- - **Size is optional** - Size field does not affect diff logic, only used for storage tracking
418
- - **Deterministic behavior** - Backend-driven, no assumptions about frontend behavior
419
-
420
- ### v1.2.6 (2025-04-20)
421
-
422
- - Refactored getNestedValue: access key first, then handle array segments
423
- - Refactored setNestedValue: proper handling of empty brackets [] and indexed arrays [0]
424
- - Robust parsing of paths: user.profile.image, gallery[].photo, variants[0].images[].url
425
-
426
- ### v1.2.5 (2025-04-20)
427
-
428
- - Rewrote parseFieldPath to split by dot then parse each segment (fixes regex state bugs)
429
- - Fixed getNestedValue array traversal when next key is a property (not array/index)
430
- - Fixed setNestedValue for empty array brackets `[]` and indexed arrays `[0]`
431
- - Added null/undefined guards throughout
432
- - Supports: `gallery[].photo`, `variants[].images[].url`, `a[].b[0].c`
433
-
434
- ### v1.2.4 (2025-04-20)
435
-
436
- - Fixed parseFieldPath regex to handle keys containing dots
437
- - Fixed parseFieldPath empty bracket handling (`[]` now correctly returns `undefined` for arrayIndex)
438
- - Fixed getNestedValue array traversal for paths like `variants[].photo`
439
- - Added null/undefined guards in array field processing methods
440
- - Improved safety for deeply nested array structures
441
-
442
- ### v1.2.3 (2025-04-13)
443
-
444
- - Added AccessModeError for private mode public URL generation
445
-
446
- ## License
447
-
448
- MIT
1
+ # nestjs-r2-storage
2
+
3
+ [![npm version](https://img.shields.io/npm/v/nestjs-r2-storage.svg)](https://www.npmjs.com/package/nestjs-r2-storage)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ ![Nestjs R2 Storage](nestjs-r2-storage.gif)
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