nestjs-r2-storage 1.3.2 → 1.3.4

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,437 @@
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 an existing object with new photos. Uses base filename comparison to avoid unnecessary deletes/uploads:
305
-
306
- - Extracts base filename (strips extension + trailing timestamps)
307
- - Only deletes old photo if base filename is different
308
- - Only generates upload URL if base filename is different
309
-
310
- ```typescript
311
- const result = await photoManager.updateObjectWithPhotos(
312
- newPayload, // payload with new photo data
313
- existingObject, // existing object with old photo data
314
- photoFields,
315
- );
316
-
317
- // result = {
318
- // updatedPayload: { ... }, // merged payload
319
- // uploadUrls: [{ field, fileKey, uploadUrl, publicUrl }], // new upload URLs
320
- // storageIncrease: 1000, // new files size
321
- // storageDecrease: 500, // deleted files size
322
- // deletedFiles: ['old-file.png'] // files that were deleted
323
- // }
324
- ```
325
-
326
- #### deletePhotosFromObject()
327
-
328
- Deletes all photos from object.
329
-
330
- ```typescript
331
- const result = await photoManager.deletePhotosFromObject(product, photoFields);
332
-
333
- // result = {
334
- // deletedFiles: ['file1.png', 'file2.jpg'],
335
- // totalStorageFreed: 25000
336
- // }
337
- ```
338
-
339
- ## Field Path Syntax
340
-
341
- ### Simple Nested Fields
342
-
343
- ```
344
- shop.logo
345
- profile.avatar
346
- user.profile.image
347
- ```
348
-
349
- ### Array Fields
350
-
351
- ```
352
- gallery[].photo -> gallery[0].photo, gallery[1].photo, ...
353
- products[].image -> products[0].image, products[1].image, ...
354
- variants[].images[] -> variants[0].images[0], variants[0].images[1], ...
355
- ```
356
-
357
- ### Supported Patterns
358
-
359
- | Path | Description |
360
- | -------------------------- | ------------------------------- |
361
- | `shop.logo` | Simple nested field |
362
- | `user.profile.image` | Deeply nested with dots |
363
- | `gallery[].photo` | Array of objects |
364
- | `products[].images[]` | Array containing array |
365
- | `variants[0].images[].url` | Indexed array with nested array |
366
-
367
- ## Configuration Options
368
-
369
- | Option | Type | Required | Description |
370
- | ----------------- | ------ | -------- | ------------------------------------------------------------------- |
371
- | `endpoint` | string | Yes | R2 endpoint URL |
372
- | `accessKeyId` | string | Yes | R2 access key ID |
373
- | `secretAccessKey` | string | Yes | R2 secret access key |
374
- | `bucketName` | string | Yes | R2 bucket name |
375
- | `region` | string | No | AWS region (default: 'auto') |
376
- | `publicUrlBase` | string | No | Base URL for public access |
377
- | `signedUrlExpiry` | number | No | Signed URL expiry in seconds (default: 3600) |
378
- | `accessMode` | string | No | Access mode: `private`, `public-read`, `hybrid` (default: `hybrid`) |
379
-
380
- ## Error Handling
381
-
382
- ### AccessModeError
383
-
384
- Thrown when attempting to generate public URLs in `private` access mode.
385
-
386
- ```typescript
387
- import { AccessModeError } from "nestjs-r2-storage";
388
-
389
- try {
390
- const result = await cloudflare.getUploadUrl("file.png", 1024);
391
- } catch (error) {
392
- if (error instanceof AccessModeError) {
393
- console.log(error.message); // "Public URL generation is not allowed in 'private' access mode..."
394
- }
395
- }
396
- ```
397
-
398
- ## Async Configuration
399
-
400
- ```typescript
401
- R2StorageModule.forRootAsync({
402
- useFactory: () => ({
403
- endpoint: process.env.R2_ENDPOINT,
404
- accessKeyId: process.env.R2_ACCESS_KEY,
405
- secretAccessKey: process.env.R2_SECRET_KEY,
406
- bucketName: process.env.R2_BUCKET,
407
- }),
408
- });
409
- ```
410
-
411
- ## Changelog
412
-
413
- ### v1.3.2 (2026-04-23)
414
-
415
- - Fixed: `createObjectWithPhotos` and `updateObjectWithPhotos` now work without `sizeField` defined
416
- - Previously required `sizeField > 0`, now only validates size when `sizeField` is explicitly provided
417
-
418
- ### v1.3.1 (2026-04-22)
419
-
420
- - Refactored getNestedValue: access key first, then handle array segments
421
- - Refactored setNestedValue: proper handling of empty brackets [] and indexed arrays [0]
422
- - Robust parsing of paths: user.profile.image, gallery[].photo, variants[0].images[].url
423
-
424
- ### v1.2.5 (2025-04-20)
425
-
426
- - Rewrote parseFieldPath to split by dot then parse each segment (fixes regex state bugs)
427
- - Fixed getNestedValue array traversal when next key is a property (not array/index)
428
- - Fixed setNestedValue for empty array brackets `[]` and indexed arrays `[0]`
429
- - Added null/undefined guards throughout
430
- - Supports: `gallery[].photo`, `variants[].images[].url`, `a[].b[0].c`
431
-
432
- ### v1.2.4 (2025-04-20)
433
-
434
- - Fixed parseFieldPath regex to handle keys containing dots
435
- - Fixed parseFieldPath empty bracket handling (`[]` now correctly returns `undefined` for arrayIndex)
436
- - Fixed getNestedValue array traversal for paths like `variants[].photo`
437
- - Added null/undefined guards in array field processing methods
438
- - Improved safety for deeply nested array structures
439
-
440
- ### v1.2.3 (2025-04-13)
441
-
442
- - Added AccessModeError for private mode public URL generation
443
-
444
- ## License
445
-
446
- 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
+ - **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.2.6 (2025-04-20)
410
+
411
+ - Refactored getNestedValue: access key first, then handle array segments
412
+ - Refactored setNestedValue: proper handling of empty brackets [] and indexed arrays [0]
413
+ - Robust parsing of paths: user.profile.image, gallery[].photo, variants[0].images[].url
414
+
415
+ ### v1.2.5 (2025-04-20)
416
+
417
+ - Rewrote parseFieldPath to split by dot then parse each segment (fixes regex state bugs)
418
+ - Fixed getNestedValue array traversal when next key is a property (not array/index)
419
+ - Fixed setNestedValue for empty array brackets `[]` and indexed arrays `[0]`
420
+ - Added null/undefined guards throughout
421
+ - Supports: `gallery[].photo`, `variants[].images[].url`, `a[].b[0].c`
422
+
423
+ ### v1.2.4 (2025-04-20)
424
+
425
+ - Fixed parseFieldPath regex to handle keys containing dots
426
+ - Fixed parseFieldPath empty bracket handling (`[]` now correctly returns `undefined` for arrayIndex)
427
+ - Fixed getNestedValue array traversal for paths like `variants[].photo`
428
+ - Added null/undefined guards in array field processing methods
429
+ - Improved safety for deeply nested array structures
430
+
431
+ ### v1.2.3 (2025-04-13)
432
+
433
+ - Added AccessModeError for private mode public URL generation
434
+
435
+ ## License
436
+
437
+ MIT