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