mbkbucket 4.1.0 → 4.3.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/lib/cli.js +1 -1
- package/lib/src/app.js +13 -25
- package/lib/src/controllers/api.controller.js +2 -1
- package/lib/src/controllers/dashboard.controller.js +2 -2
- package/lib/src/controllers/info.controller.js +2 -1
- package/lib/src/controllers/view.controller.js +2 -1
- package/lib/src/core/metrics.js +134 -134
- package/lib/src/core/storage/capabilities.js +58 -58
- package/lib/src/core/storage/errors.js +57 -57
- package/lib/src/core/storage/gdrive-auth.js +157 -157
- package/lib/src/core/storage/gdrive-provider.js +715 -714
- package/lib/src/core/storage/index.js +17 -17
- package/lib/src/core/storage/local-provider.js +507 -506
- package/lib/src/core/storage/models.js +121 -120
- package/lib/src/core/storage/path-strategy.js +85 -78
- package/lib/src/core/storage/s3-provider.js +472 -471
- package/lib/src/core/storage/storage-manager.js +226 -223
- package/lib/src/core/storage/storage-provider.js +146 -146
- package/lib/src/middleware/error-handler.js +15 -52
- package/lib/src/middleware/rate-limiter.js +1 -1
- package/lib/src/services/storage.service.js +1 -1
- package/lib/src/utils/cleanup-scheduler.js +91 -91
- package/lib/src/utils/helpers.js +2 -45
- package/package.json +3 -2
- package/views/bucket.hbs +4 -4
- package/views/bucketadmincontent.hbs +2 -2
- package/views/mbkbucket_info.hbs +4 -4
|
@@ -1,714 +1,715 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MBKBucket - Google Drive Storage Provider
|
|
3
|
-
* Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
|
|
4
|
-
* Licensed under the MIT License.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { Readable } from "node:stream";
|
|
8
|
-
import { StorageProvider } from "./storage-provider.js";
|
|
9
|
-
import { StorageCapabilities } from "./capabilities.js";
|
|
10
|
-
import { StorageFile, StorageFolder, StorageListResult } from "./models.js";
|
|
11
|
-
import { StorageNotFoundError, StorageConflictError, StorageAccessDeniedError, StorageValidationError, StorageConfigError, StorageError } from "./errors.js";
|
|
12
|
-
import { refreshGoogleAccessToken, getServiceAccountAccessToken } from "./gdrive-auth.js";
|
|
13
|
-
import { createLogger } from "#logger";
|
|
14
|
-
import { nowIso,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
this.
|
|
27
|
-
this.
|
|
28
|
-
this.
|
|
29
|
-
this.
|
|
30
|
-
this._setFolderCache('
|
|
31
|
-
this._setFolderCache('
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
this._folderCache.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
this.
|
|
56
|
-
this._setFolderCache('
|
|
57
|
-
this._setFolderCache('
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
this.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
this.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
headers.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
this.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
let
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
let
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
const
|
|
232
|
-
const
|
|
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
|
-
const
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
const
|
|
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
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
const
|
|
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
|
-
const
|
|
406
|
-
const
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
'
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
'Content-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (
|
|
498
|
-
if (
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
if (res.status ===
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
const
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
const
|
|
692
|
-
const
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* MBKBucket - Google Drive Storage Provider
|
|
3
|
+
* Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
|
|
4
|
+
* Licensed under the MIT License.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
import { StorageProvider } from "./storage-provider.js";
|
|
9
|
+
import { StorageCapabilities } from "./capabilities.js";
|
|
10
|
+
import { StorageFile, StorageFolder, StorageListResult } from "./models.js";
|
|
11
|
+
import { StorageNotFoundError, StorageConflictError, StorageAccessDeniedError, StorageValidationError, StorageConfigError, StorageError } from "./errors.js";
|
|
12
|
+
import { refreshGoogleAccessToken, getServiceAccountAccessToken } from "./gdrive-auth.js";
|
|
13
|
+
import { createLogger } from "#logger";
|
|
14
|
+
import { nowIso, getFolderPath } from "#helpers";
|
|
15
|
+
import { trimSlashes, trimLeadingSlashes, getBaseName } from "mbkutils";
|
|
16
|
+
|
|
17
|
+
const debugGDrive = createLogger('gdrive-provider');
|
|
18
|
+
|
|
19
|
+
function escapeGdriveQuery(str = '') {
|
|
20
|
+
return String(str).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class GoogleDriveStorageProvider extends StorageProvider {
|
|
24
|
+
constructor(name, config = {}) {
|
|
25
|
+
super(name, config);
|
|
26
|
+
this.rootFolderId = config.folder_id || config.root_folder_id || 'root';
|
|
27
|
+
this._accessToken = config.access_token || null;
|
|
28
|
+
this._tokenExpiry = config.expiry_date || 0;
|
|
29
|
+
this._folderCache = new Map();
|
|
30
|
+
this._setFolderCache('', this.rootFolderId);
|
|
31
|
+
this._setFolderCache('.', this.rootFolderId);
|
|
32
|
+
this._setFolderCache('/', this.rootFolderId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_setFolderCache(path, id) {
|
|
36
|
+
if (this._folderCache.size >= 500) {
|
|
37
|
+
const oldestKey = this._folderCache.keys().next().value;
|
|
38
|
+
this._folderCache.delete(oldestKey);
|
|
39
|
+
}
|
|
40
|
+
this._folderCache.set(path, { id, expires: Date.now() + 300000 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_getFolderCache(path) {
|
|
44
|
+
const entry = this._folderCache.get(path);
|
|
45
|
+
if (!entry) return null;
|
|
46
|
+
if (typeof entry === 'object' && entry.expires && entry.expires < Date.now()) {
|
|
47
|
+
this._folderCache.delete(path);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return typeof entry === 'object' && entry.id ? entry.id : entry;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_invalidateFolderCache(prefix = '') {
|
|
54
|
+
if (!prefix) {
|
|
55
|
+
this._folderCache.clear();
|
|
56
|
+
this._setFolderCache('', this.rootFolderId);
|
|
57
|
+
this._setFolderCache('.', this.rootFolderId);
|
|
58
|
+
this._setFolderCache('/', this.rootFolderId);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this._folderCache.delete(prefix);
|
|
62
|
+
for (const k of this._folderCache.keys()) {
|
|
63
|
+
if (k.startsWith(`${prefix}/`)) {
|
|
64
|
+
this._folderCache.delete(k);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get type() {
|
|
70
|
+
return 'gdrive';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get capabilities() {
|
|
74
|
+
return new StorageCapabilities({
|
|
75
|
+
multipart: false,
|
|
76
|
+
resumable: false,
|
|
77
|
+
nativeFolders: true,
|
|
78
|
+
ranges: true,
|
|
79
|
+
copy: true,
|
|
80
|
+
move: true,
|
|
81
|
+
search: true,
|
|
82
|
+
directDownloadUrl: true,
|
|
83
|
+
versioning: false,
|
|
84
|
+
publicUrls: true,
|
|
85
|
+
metadata: true
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getAccessToken() {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
if (this._accessToken && this._tokenExpiry > now + 60000) {
|
|
92
|
+
return this._accessToken;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const {
|
|
96
|
+
client_id,
|
|
97
|
+
client_secret,
|
|
98
|
+
refresh_token,
|
|
99
|
+
client_email,
|
|
100
|
+
service_account_email,
|
|
101
|
+
private_key,
|
|
102
|
+
service_account_key
|
|
103
|
+
} = this.config;
|
|
104
|
+
|
|
105
|
+
// Refresh token flow
|
|
106
|
+
if (client_id && client_secret && refresh_token) {
|
|
107
|
+
debugGDrive('Refreshing OAuth access token for connection "%s"', this.name);
|
|
108
|
+
const res = await refreshGoogleAccessToken({
|
|
109
|
+
clientId: client_id,
|
|
110
|
+
clientSecret: client_secret,
|
|
111
|
+
refreshToken: refresh_token
|
|
112
|
+
});
|
|
113
|
+
this._accessToken = res.access_token;
|
|
114
|
+
this._tokenExpiry = res.expiry_date;
|
|
115
|
+
return this._accessToken;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Service Account flow
|
|
119
|
+
const email = client_email || service_account_email;
|
|
120
|
+
const key = private_key || service_account_key;
|
|
121
|
+
if (email && key) {
|
|
122
|
+
debugGDrive('Fetching Service Account access token for "%s"', email);
|
|
123
|
+
const res = await getServiceAccountAccessToken({
|
|
124
|
+
clientEmail: email,
|
|
125
|
+
privateKey: key
|
|
126
|
+
});
|
|
127
|
+
this._accessToken = res.access_token;
|
|
128
|
+
this._tokenExpiry = res.expiry_date;
|
|
129
|
+
return this._accessToken;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this._accessToken) {
|
|
133
|
+
return this._accessToken;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
throw new StorageConfigError(`Google Drive provider '${this.name}' has no valid credentials configured (missing refresh_token or service_account).`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async _fetch(url, options = {}) {
|
|
140
|
+
const maxRetries = options.maxRetries ?? 3;
|
|
141
|
+
let attempt = 0;
|
|
142
|
+
|
|
143
|
+
while (attempt <= maxRetries) {
|
|
144
|
+
const token = await this.getAccessToken();
|
|
145
|
+
const headers = new Headers(options.headers || {});
|
|
146
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
147
|
+
|
|
148
|
+
let response = await fetch(url, { ...options, headers });
|
|
149
|
+
|
|
150
|
+
// Handle 401 token expiration retry once
|
|
151
|
+
if (response.status === 401 && attempt === 0 && (this.config.refresh_token || this.config.client_email)) {
|
|
152
|
+
debugGDrive('Received 401 from Google Drive API, retrying with refreshed token...');
|
|
153
|
+
this._accessToken = null;
|
|
154
|
+
this._tokenExpiry = 0;
|
|
155
|
+
const newToken = await this.getAccessToken();
|
|
156
|
+
headers.set('Authorization', `Bearer ${newToken}`);
|
|
157
|
+
response = await fetch(url, { ...options, headers });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Handle 429 Too Many Requests or 503/500 temporary service errors with exponential backoff
|
|
161
|
+
if ((response.status === 429 || response.status === 503 || response.status === 500) && attempt < maxRetries) {
|
|
162
|
+
attempt++;
|
|
163
|
+
const retryAfter = Number(response.headers.get('retry-after')) || 0;
|
|
164
|
+
const delayMs = retryAfter > 0 ? retryAfter * 1000 : Math.min(300 * (2 ** (attempt - 1)) + Math.random() * 150, 4000);
|
|
165
|
+
debugGDrive('Google Drive API rate limit/error (%s). Retrying attempt %s in %sms...', response.status, attempt, Math.round(delayMs));
|
|
166
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return response;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async _resolvePathToFolderId(path = '') {
|
|
175
|
+
const cleanPath = trimSlashes(path);
|
|
176
|
+
if (!cleanPath) return this.rootFolderId;
|
|
177
|
+
const cached = this._getFolderCache(cleanPath);
|
|
178
|
+
if (cached) return cached;
|
|
179
|
+
|
|
180
|
+
const segments = cleanPath.split('/').filter(Boolean);
|
|
181
|
+
let currentParentId = this.rootFolderId;
|
|
182
|
+
let currentPath = '';
|
|
183
|
+
|
|
184
|
+
for (const segment of segments) {
|
|
185
|
+
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
186
|
+
const segCached = this._getFolderCache(currentPath);
|
|
187
|
+
if (segCached) {
|
|
188
|
+
currentParentId = segCached;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const q = `'${currentParentId}' in parents and name = '${escapeGdriveQuery(segment)}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false`;
|
|
193
|
+
const url = `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(q)}&fields=files(id,name)&pageSize=1`;
|
|
194
|
+
const res = await this._fetch(url);
|
|
195
|
+
const data = await res.json();
|
|
196
|
+
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
throw new StorageError(`Google Drive API error resolving folder '${segment}': ${data.error?.message || res.statusText}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!data.files || !data.files.length) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
currentParentId = data.files[0].id;
|
|
206
|
+
this._setFolderCache(currentPath, currentParentId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return currentParentId;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async _ensureFolderPath(path = '') {
|
|
213
|
+
const cleanPath = trimSlashes(path);
|
|
214
|
+
if (!cleanPath) return this.rootFolderId;
|
|
215
|
+
const cached = this._getFolderCache(cleanPath);
|
|
216
|
+
if (cached) return cached;
|
|
217
|
+
|
|
218
|
+
const segments = cleanPath.split('/').filter(Boolean);
|
|
219
|
+
let currentParentId = this.rootFolderId;
|
|
220
|
+
let currentPath = '';
|
|
221
|
+
|
|
222
|
+
for (const segment of segments) {
|
|
223
|
+
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
224
|
+
const segCached = this._getFolderCache(currentPath);
|
|
225
|
+
if (segCached) {
|
|
226
|
+
currentParentId = segCached;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const q = `'${currentParentId}' in parents and name = '${escapeGdriveQuery(segment)}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false`;
|
|
231
|
+
const url = `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(q)}&fields=files(id,name)&pageSize=1`;
|
|
232
|
+
const res = await this._fetch(url);
|
|
233
|
+
const data = await res.json();
|
|
234
|
+
|
|
235
|
+
if (!res.ok) {
|
|
236
|
+
throw new StorageError(`Google Drive API error checking folder '${segment}': ${data.error?.message || res.statusText}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (data.files && data.files.length) {
|
|
240
|
+
currentParentId = data.files[0].id;
|
|
241
|
+
} else {
|
|
242
|
+
debugGDrive('Creating Google Drive folder "%s" under parent "%s"', segment, currentParentId);
|
|
243
|
+
const createRes = await this._fetch('https://www.googleapis.com/drive/v3/files', {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers: { 'Content-Type': 'application/json' },
|
|
246
|
+
body: JSON.stringify({
|
|
247
|
+
name: segment,
|
|
248
|
+
mimeType: 'application/vnd.google-apps.folder',
|
|
249
|
+
parents: [currentParentId]
|
|
250
|
+
})
|
|
251
|
+
});
|
|
252
|
+
const createData = await createRes.json();
|
|
253
|
+
if (!createRes.ok) {
|
|
254
|
+
throw new StorageError(`Failed to create Google Drive folder '${segment}': ${createData.error?.message || createRes.statusText}`);
|
|
255
|
+
}
|
|
256
|
+
currentParentId = createData.id;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this._setFolderCache(currentPath, currentParentId);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return currentParentId;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async _resolveKeyToFile(key) {
|
|
266
|
+
const cleanKey = trimLeadingSlashes(key);
|
|
267
|
+
const folderPath = getFolderPath(cleanKey);
|
|
268
|
+
const fileName = getBaseName(cleanKey);
|
|
269
|
+
|
|
270
|
+
const folderId = await this._resolvePathToFolderId(folderPath);
|
|
271
|
+
if (!folderId) return null;
|
|
272
|
+
|
|
273
|
+
const q = `'${folderId}' in parents and name = '${escapeGdriveQuery(fileName)}' and trashed = false`;
|
|
274
|
+
const fields = 'files(id,name,mimeType,size,modifiedTime,createdTime,md5Checksum,webViewLink,webContentLink,properties,parents)';
|
|
275
|
+
const url = `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(q)}&fields=${encodeURIComponent(fields)}&pageSize=1`;
|
|
276
|
+
|
|
277
|
+
const res = await this._fetch(url);
|
|
278
|
+
const data = await res.json();
|
|
279
|
+
if (!res.ok || !data.files?.length) return null;
|
|
280
|
+
|
|
281
|
+
return data.files[0];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Core Storage Operations
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
async checkHealth() {
|
|
289
|
+
const startTime = Date.now();
|
|
290
|
+
try {
|
|
291
|
+
const url = 'https://www.googleapis.com/drive/v3/about?fields=user,storageQuota';
|
|
292
|
+
const res = await this._fetch(url);
|
|
293
|
+
const data = await res.json();
|
|
294
|
+
|
|
295
|
+
if (!res.ok) {
|
|
296
|
+
throw new StorageError(data.error?.message || res.statusText);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
status: 'healthy',
|
|
301
|
+
providerType: 'gdrive',
|
|
302
|
+
responseTime: Date.now() - startTime,
|
|
303
|
+
bucket: this.name,
|
|
304
|
+
user: data.user?.emailAddress || data.user?.displayName || 'Google Account',
|
|
305
|
+
storageQuota: data.storageQuota,
|
|
306
|
+
checkedAt: nowIso()
|
|
307
|
+
};
|
|
308
|
+
} catch (error) {
|
|
309
|
+
return {
|
|
310
|
+
status: 'unhealthy',
|
|
311
|
+
providerType: 'gdrive',
|
|
312
|
+
error: error.message,
|
|
313
|
+
bucket: this.name,
|
|
314
|
+
checkedAt: nowIso()
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async listFiles(prefix = '', options = {}) {
|
|
320
|
+
try {
|
|
321
|
+
const {
|
|
322
|
+
maxKeys = 1000,
|
|
323
|
+
continuationToken = null
|
|
324
|
+
} = options;
|
|
325
|
+
|
|
326
|
+
const cleanPrefix = trimSlashes(prefix);
|
|
327
|
+
const parentId = await this._resolvePathToFolderId(cleanPrefix);
|
|
328
|
+
|
|
329
|
+
if (!parentId) {
|
|
330
|
+
return new StorageListResult({
|
|
331
|
+
contents: [],
|
|
332
|
+
commonPrefixes: [],
|
|
333
|
+
totalFiles: 0,
|
|
334
|
+
isTruncated: false,
|
|
335
|
+
nextToken: null
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const q = `'${parentId}' in parents and trashed = false`;
|
|
340
|
+
const fields = 'nextPageToken,files(id,name,mimeType,size,modifiedTime,createdTime,md5Checksum,webViewLink,webContentLink,properties)';
|
|
341
|
+
let url = `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(q)}&fields=${encodeURIComponent(fields)}&pageSize=${Math.min(maxKeys, 1000)}&orderBy=folder,name`;
|
|
342
|
+
|
|
343
|
+
if (continuationToken) {
|
|
344
|
+
url += `&pageToken=${encodeURIComponent(continuationToken)}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const res = await this._fetch(url);
|
|
348
|
+
const data = await res.json();
|
|
349
|
+
|
|
350
|
+
if (!res.ok) {
|
|
351
|
+
throw new StorageError(`Google Drive list files failed: ${data.error?.message || res.statusText}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const contents = [];
|
|
355
|
+
const commonPrefixes = [];
|
|
356
|
+
const prefixPath = cleanPrefix ? `${cleanPrefix}/` : '';
|
|
357
|
+
|
|
358
|
+
for (const item of (data.files || [])) {
|
|
359
|
+
if (item.mimeType === 'application/vnd.google-apps.folder') {
|
|
360
|
+
const folderPrefix = `${prefixPath}${item.name}/`;
|
|
361
|
+
commonPrefixes.push(new StorageFolder({
|
|
362
|
+
prefix: folderPrefix,
|
|
363
|
+
id: item.id,
|
|
364
|
+
provider: 'gdrive'
|
|
365
|
+
}));
|
|
366
|
+
this._folderCache.set(trimSlashes(folderPrefix), item.id);
|
|
367
|
+
} else {
|
|
368
|
+
const itemKey = `${prefixPath}${item.name}`;
|
|
369
|
+
contents.push(new StorageFile({
|
|
370
|
+
key: itemKey,
|
|
371
|
+
size: parseInt(item.size || 0, 10),
|
|
372
|
+
lastModified: item.modifiedTime || item.createdTime || Date.now(),
|
|
373
|
+
createdAt: item.createdTime,
|
|
374
|
+
etag: item.md5Checksum ? `"${item.md5Checksum}"` : `"${item.id}"`,
|
|
375
|
+
storageClass: 'GOOGLE_DRIVE',
|
|
376
|
+
provider: 'gdrive',
|
|
377
|
+
id: item.id,
|
|
378
|
+
nativeId: item.id,
|
|
379
|
+
mimeType: item.mimeType,
|
|
380
|
+
webViewLink: item.webViewLink,
|
|
381
|
+
webContentLink: item.webContentLink,
|
|
382
|
+
metadata: item.properties || {}
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return new StorageListResult({
|
|
388
|
+
contents,
|
|
389
|
+
commonPrefixes,
|
|
390
|
+
nextToken: data.nextPageToken || null,
|
|
391
|
+
isTruncated: Boolean(data.nextPageToken),
|
|
392
|
+
totalFiles: contents.length
|
|
393
|
+
});
|
|
394
|
+
} catch (error) {
|
|
395
|
+
debugGDrive('List files failed for prefix "%s": %s', prefix, error.message);
|
|
396
|
+
if (error instanceof StorageError) throw error;
|
|
397
|
+
throw new StorageError(`List files failed: ${error.message}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async uploadFile(key, fileBuffer, contentType = 'application/octet-stream', options = {}) {
|
|
402
|
+
try {
|
|
403
|
+
if (!key || !fileBuffer) throw new StorageValidationError('Key and file buffer are required');
|
|
404
|
+
|
|
405
|
+
const cleanKey = trimLeadingSlashes(key);
|
|
406
|
+
const folderPath = getFolderPath(cleanKey);
|
|
407
|
+
const fileName = getBaseName(cleanKey);
|
|
408
|
+
const { metadata = {}, preventOverwrite = false } = options;
|
|
409
|
+
|
|
410
|
+
const parentFolderId = await this._ensureFolderPath(folderPath);
|
|
411
|
+
|
|
412
|
+
// Check if file already exists
|
|
413
|
+
const existing = await this._resolveKeyToFile(cleanKey);
|
|
414
|
+
if (existing) {
|
|
415
|
+
if (preventOverwrite) {
|
|
416
|
+
throw new StorageConflictError('File already exists');
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
debugGDrive('Uploading to Google Drive: name="%s", folder="%s", size=%s', fileName, parentFolderId, fileBuffer.length);
|
|
421
|
+
|
|
422
|
+
const buffer = Buffer.isBuffer(fileBuffer) ? fileBuffer : Buffer.from(fileBuffer);
|
|
423
|
+
const uploadedAt = nowIso();
|
|
424
|
+
|
|
425
|
+
const boundary = `-------mbkbucket_gdrive_${Date.now()}`;
|
|
426
|
+
const delimiter = `\r\n--${boundary}\r\n`;
|
|
427
|
+
const closeDelimiter = `\r\n--${boundary}--`;
|
|
428
|
+
|
|
429
|
+
const metadataPart = {
|
|
430
|
+
name: fileName,
|
|
431
|
+
description: metadata.description || 'Uploaded via MBKBucket',
|
|
432
|
+
properties: {
|
|
433
|
+
'uploaded-at': uploadedAt,
|
|
434
|
+
'upload-source': 'web-portal',
|
|
435
|
+
...metadata
|
|
436
|
+
},
|
|
437
|
+
...(!existing && { parents: [parentFolderId] })
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const metaHeader = `Content-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadataPart)}`;
|
|
441
|
+
const mediaHeader = `Content-Type: ${contentType}\r\n\r\n`;
|
|
442
|
+
|
|
443
|
+
const bodyPayload = Buffer.concat([
|
|
444
|
+
Buffer.from(delimiter + metaHeader + delimiter + mediaHeader),
|
|
445
|
+
buffer,
|
|
446
|
+
Buffer.from(closeDelimiter)
|
|
447
|
+
]);
|
|
448
|
+
|
|
449
|
+
const uploadUrl = existing
|
|
450
|
+
? `https://www.googleapis.com/upload/drive/v3/files/${existing.id}?uploadType=multipart&fields=id,name,mimeType,size,modifiedTime,webViewLink,webContentLink`
|
|
451
|
+
: `https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,mimeType,size,modifiedTime,webViewLink,webContentLink`;
|
|
452
|
+
|
|
453
|
+
const method = existing ? 'PATCH' : 'POST';
|
|
454
|
+
const res = await this._fetch(uploadUrl, {
|
|
455
|
+
method,
|
|
456
|
+
headers: {
|
|
457
|
+
'Content-Type': `multipart/related; boundary=${boundary}`,
|
|
458
|
+
'Content-Length': String(bodyPayload.length)
|
|
459
|
+
},
|
|
460
|
+
body: bodyPayload
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const data = await res.json();
|
|
464
|
+
if (!res.ok) {
|
|
465
|
+
throw new StorageError(`Google Drive upload failed: ${data.error?.message || res.statusText}`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
key: cleanKey,
|
|
470
|
+
fileSize: buffer.length,
|
|
471
|
+
contentType,
|
|
472
|
+
uploadedAt,
|
|
473
|
+
id: data.id,
|
|
474
|
+
webViewLink: data.webViewLink,
|
|
475
|
+
webContentLink: data.webContentLink
|
|
476
|
+
};
|
|
477
|
+
} catch (error) {
|
|
478
|
+
debugGDrive('Upload failed for key "%s": %s', key, error.message);
|
|
479
|
+
if (error instanceof StorageError) throw error;
|
|
480
|
+
throw new StorageError(`Upload failed: ${error.message}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async downloadFile(key, options = {}) {
|
|
485
|
+
try {
|
|
486
|
+
if (!key) throw new StorageValidationError('Key is required');
|
|
487
|
+
const cleanKey = trimLeadingSlashes(key);
|
|
488
|
+
|
|
489
|
+
const file = await this._resolveKeyToFile(cleanKey);
|
|
490
|
+
if (!file) {
|
|
491
|
+
throw new StorageNotFoundError(`File not found: ${cleanKey}`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const { range, ifNoneMatch, ifModifiedSince } = options;
|
|
495
|
+
const headers = {};
|
|
496
|
+
|
|
497
|
+
if (range) headers['Range'] = range;
|
|
498
|
+
if (ifNoneMatch) headers['If-None-Match'] = ifNoneMatch;
|
|
499
|
+
if (ifModifiedSince) headers['If-Modified-Since'] = new Date(ifModifiedSince).toUTCString();
|
|
500
|
+
|
|
501
|
+
let downloadUrl;
|
|
502
|
+
const isGoogleDoc = file.mimeType?.startsWith('application/vnd.google-apps.');
|
|
503
|
+
|
|
504
|
+
if (isGoogleDoc) {
|
|
505
|
+
const exportMime = file.mimeType.includes('spreadsheet')
|
|
506
|
+
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
507
|
+
: file.mimeType.includes('presentation')
|
|
508
|
+
? 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
509
|
+
: 'application/pdf';
|
|
510
|
+
downloadUrl = `https://www.googleapis.com/drive/v3/files/${file.id}/export?mimeType=${encodeURIComponent(exportMime)}`;
|
|
511
|
+
} else {
|
|
512
|
+
downloadUrl = `https://www.googleapis.com/drive/v3/files/${file.id}?alt=media`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const res = await this._fetch(downloadUrl, { headers });
|
|
516
|
+
|
|
517
|
+
if (res.status === 304) {
|
|
518
|
+
return { notModified: true, key: cleanKey };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (!res.ok) {
|
|
522
|
+
if (res.status === 404) throw new StorageNotFoundError(`File not found: ${cleanKey}`);
|
|
523
|
+
if (res.status === 403) throw new StorageAccessDeniedError(`Access denied for file: ${cleanKey}`);
|
|
524
|
+
const errText = await res.text().catch(() => '');
|
|
525
|
+
throw new StorageError(`Download failed with status ${res.status}: ${errText}`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const bodyStream = Readable.fromWeb(res.body);
|
|
529
|
+
const contentLength = res.headers.get('content-length')
|
|
530
|
+
? parseInt(res.headers.get('content-length'), 10)
|
|
531
|
+
: file.size ? parseInt(file.size, 10) : undefined;
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
Body: bodyStream,
|
|
535
|
+
ContentType: res.headers.get('content-type') || file.mimeType || 'application/octet-stream',
|
|
536
|
+
ContentLength: contentLength,
|
|
537
|
+
ETag: res.headers.get('etag') || (file.md5Checksum ? `"${file.md5Checksum}"` : `"${file.id}"`),
|
|
538
|
+
LastModified: new Date(file.modifiedTime || Date.now()),
|
|
539
|
+
key: cleanKey,
|
|
540
|
+
id: file.id
|
|
541
|
+
};
|
|
542
|
+
} catch (error) {
|
|
543
|
+
debugGDrive('Download failed for key "%s": %s', key, error.message);
|
|
544
|
+
if (error instanceof StorageError) throw error;
|
|
545
|
+
throw new StorageError(`Download failed: ${error.message}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async deleteFile(key, _options = {}) {
|
|
550
|
+
try {
|
|
551
|
+
if (!key) throw new StorageValidationError('Key is required');
|
|
552
|
+
const cleanKey = trimLeadingSlashes(key);
|
|
553
|
+
|
|
554
|
+
const file = await this._resolveKeyToFile(cleanKey);
|
|
555
|
+
if (!file) {
|
|
556
|
+
return { key: cleanKey, deletedAt: nowIso(), notFound: true };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const url = `https://www.googleapis.com/drive/v3/files/${file.id}`;
|
|
560
|
+
const res = await this._fetch(url, { method: 'DELETE' });
|
|
561
|
+
|
|
562
|
+
if (!res.ok && res.status !== 404) {
|
|
563
|
+
const data = await res.json().catch(() => ({}));
|
|
564
|
+
throw new StorageError(`Failed to delete Google Drive file: ${data.error?.message || res.statusText}`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return { key: cleanKey, id: file.id, deletedAt: nowIso() };
|
|
568
|
+
} catch (error) {
|
|
569
|
+
debugGDrive('Delete file failed for key "%s": %s', key, error.message);
|
|
570
|
+
if (error instanceof StorageError) throw error;
|
|
571
|
+
throw new StorageError(`Delete failed: ${error.message}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async deleteFolder(prefix, _options = {}) {
|
|
576
|
+
try {
|
|
577
|
+
if (!prefix) throw new StorageValidationError('Prefix is required');
|
|
578
|
+
const cleanPrefix = trimSlashes(prefix);
|
|
579
|
+
|
|
580
|
+
const folderId = await this._resolvePathToFolderId(cleanPrefix);
|
|
581
|
+
if (!folderId || folderId === this.rootFolderId) {
|
|
582
|
+
return { deletedCount: 0, prefix: cleanPrefix, deletedAt: nowIso() };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const url = `https://www.googleapis.com/drive/v3/files/${folderId}`;
|
|
586
|
+
const res = await this._fetch(url, { method: 'DELETE' });
|
|
587
|
+
|
|
588
|
+
if (!res.ok && res.status !== 404) {
|
|
589
|
+
const data = await res.json().catch(() => ({}));
|
|
590
|
+
throw new StorageError(`Failed to delete Google Drive folder: ${data.error?.message || res.statusText}`);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
this._invalidateFolderCache(cleanPrefix);
|
|
594
|
+
|
|
595
|
+
return { deletedCount: 1, prefix: cleanPrefix, deletedAt: nowIso() };
|
|
596
|
+
} catch (error) {
|
|
597
|
+
debugGDrive('Delete folder failed for "%s": %s', prefix, error.message);
|
|
598
|
+
if (error instanceof StorageError) throw error;
|
|
599
|
+
throw new StorageError(`Delete folder failed: ${error.message}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async createFolder(key, _options = {}) {
|
|
604
|
+
try {
|
|
605
|
+
const cleanKey = trimSlashes(key);
|
|
606
|
+
const folderId = await this._ensureFolderPath(cleanKey);
|
|
607
|
+
return { key: `${cleanKey}/`, id: folderId, success: true, message: 'Folder created' };
|
|
608
|
+
} catch (error) {
|
|
609
|
+
debugGDrive('Create folder failed for "%s": %s', key, error.message);
|
|
610
|
+
if (error instanceof StorageError) throw error;
|
|
611
|
+
throw new StorageError(`Create folder failed: ${error.message}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async getFileMetadata(key, _options = {}) {
|
|
616
|
+
try {
|
|
617
|
+
if (!key) throw new StorageValidationError('Key is required');
|
|
618
|
+
const cleanKey = trimLeadingSlashes(key);
|
|
619
|
+
|
|
620
|
+
const file = await this._resolveKeyToFile(cleanKey);
|
|
621
|
+
const queriedAt = nowIso();
|
|
622
|
+
|
|
623
|
+
if (!file) {
|
|
624
|
+
return { key: cleanKey, exists: false, queriedAt };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return {
|
|
628
|
+
key: cleanKey,
|
|
629
|
+
exists: true,
|
|
630
|
+
id: file.id,
|
|
631
|
+
ContentLength: file.size ? parseInt(file.size, 10) : 0,
|
|
632
|
+
ContentType: file.mimeType || 'application/octet-stream',
|
|
633
|
+
LastModified: new Date(file.modifiedTime || file.createdTime || Date.now()),
|
|
634
|
+
ETag: file.md5Checksum ? `"${file.md5Checksum}"` : `"${file.id}"`,
|
|
635
|
+
Metadata: file.properties || {},
|
|
636
|
+
webViewLink: file.webViewLink,
|
|
637
|
+
webContentLink: file.webContentLink,
|
|
638
|
+
queriedAt
|
|
639
|
+
};
|
|
640
|
+
} catch (error) {
|
|
641
|
+
debugGDrive('Get file metadata failed for "%s": %s', key, error.message);
|
|
642
|
+
return { key, exists: false, queriedAt: nowIso(), error: error.message };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async copyFile(sourceKey, destKey, _options = {}) {
|
|
647
|
+
this.assertCapability('copy', 'copyFile');
|
|
648
|
+
try {
|
|
649
|
+
const srcClean = trimLeadingSlashes(sourceKey);
|
|
650
|
+
const dstClean = trimLeadingSlashes(destKey);
|
|
651
|
+
|
|
652
|
+
const file = await this._resolveKeyToFile(srcClean);
|
|
653
|
+
if (!file) throw new StorageNotFoundError(`Source file not found: ${srcClean}`);
|
|
654
|
+
|
|
655
|
+
const dstFolder = getFolderPath(dstClean);
|
|
656
|
+
const dstName = getBaseName(dstClean);
|
|
657
|
+
const targetFolderId = await this._ensureFolderPath(dstFolder);
|
|
658
|
+
|
|
659
|
+
const copyUrl = `https://www.googleapis.com/drive/v3/files/${file.id}/copy`;
|
|
660
|
+
const res = await this._fetch(copyUrl, {
|
|
661
|
+
method: 'POST',
|
|
662
|
+
headers: { 'Content-Type': 'application/json' },
|
|
663
|
+
body: JSON.stringify({
|
|
664
|
+
name: dstName,
|
|
665
|
+
parents: [targetFolderId]
|
|
666
|
+
})
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const data = await res.json();
|
|
670
|
+
if (!res.ok) {
|
|
671
|
+
throw new StorageError(`Failed to copy Google Drive file: ${data.error?.message || res.statusText}`);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return { sourceKey: srcClean, destKey: dstClean, id: data.id, copiedAt: nowIso() };
|
|
675
|
+
} catch (error) {
|
|
676
|
+
debugGDrive('Copy failed from "%s" to "%s": %s', sourceKey, destKey, error.message);
|
|
677
|
+
if (error instanceof StorageError) throw error;
|
|
678
|
+
throw new StorageError(`Copy failed: ${error.message}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async moveFile(sourceKey, destKey, _options = {}) {
|
|
683
|
+
this.assertCapability('move', 'moveFile');
|
|
684
|
+
try {
|
|
685
|
+
const srcClean = trimLeadingSlashes(sourceKey);
|
|
686
|
+
const dstClean = trimLeadingSlashes(destKey);
|
|
687
|
+
|
|
688
|
+
const file = await this._resolveKeyToFile(srcClean);
|
|
689
|
+
if (!file) throw new StorageNotFoundError(`Source file not found: ${srcClean}`);
|
|
690
|
+
|
|
691
|
+
const dstFolder = getFolderPath(dstClean);
|
|
692
|
+
const dstName = getBaseName(dstClean);
|
|
693
|
+
const targetFolderId = await this._ensureFolderPath(dstFolder);
|
|
694
|
+
const currentParentId = file.parents?.[0] || this.rootFolderId;
|
|
695
|
+
|
|
696
|
+
const updateUrl = `https://www.googleapis.com/drive/v3/files/${file.id}?addParents=${targetFolderId}&removeParents=${currentParentId}&fields=id,name,parents`;
|
|
697
|
+
const res = await this._fetch(updateUrl, {
|
|
698
|
+
method: 'PATCH',
|
|
699
|
+
headers: { 'Content-Type': 'application/json' },
|
|
700
|
+
body: JSON.stringify({ name: dstName })
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const data = await res.json();
|
|
704
|
+
if (!res.ok) {
|
|
705
|
+
throw new StorageError(`Failed to move Google Drive file: ${data.error?.message || res.statusText}`);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return { sourceKey: srcClean, destKey: dstClean, id: data.id, movedAt: nowIso() };
|
|
709
|
+
} catch (error) {
|
|
710
|
+
debugGDrive('Move failed from "%s" to "%s": %s', sourceKey, destKey, error.message);
|
|
711
|
+
if (error instanceof StorageError) throw error;
|
|
712
|
+
throw new StorageError(`Move failed: ${error.message}`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|