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