mbkbucket 4.0.0 → 4.1.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 (40) hide show
  1. package/.env.example +16 -15
  2. package/README.md +613 -561
  3. package/index.js +36 -33
  4. package/lib/cli.js +239 -239
  5. package/lib/public/bucketadmin.css +2042 -2042
  6. package/lib/public/bucketadmin.js +1844 -1622
  7. package/lib/public/mbkbucket-helper-demo.html +0 -58
  8. package/lib/public/mbkbucket-helper.js +1 -9
  9. package/lib/src/config/validation.js +91 -86
  10. package/lib/src/controllers/api.controller.js +486 -459
  11. package/lib/src/controllers/dashboard.controller.js +69 -69
  12. package/lib/src/controllers/info.controller.js +13 -13
  13. package/lib/src/controllers/view.controller.js +234 -233
  14. package/lib/src/core/metrics.js +134 -0
  15. package/lib/src/core/storage/capabilities.js +58 -0
  16. package/lib/src/core/storage/errors.js +0 -5
  17. package/lib/src/core/storage/gdrive-auth.js +0 -36
  18. package/lib/src/core/storage/gdrive-provider.js +101 -43
  19. package/lib/src/core/storage/index.js +4 -1
  20. package/lib/src/core/storage/local-provider.js +507 -0
  21. package/lib/src/core/storage/models.js +90 -50
  22. package/lib/src/core/storage/path-strategy.js +14 -1
  23. package/lib/src/core/storage/s3-provider.js +17 -37
  24. package/lib/src/core/storage/storage-manager.js +70 -50
  25. package/lib/src/core/storage/storage-provider.js +49 -120
  26. package/lib/src/middleware/bucket-resolver.js +37 -37
  27. package/lib/src/routes/api.routes.js +4 -1
  28. package/lib/src/routes/index.js +60 -60
  29. package/lib/src/routes/view.routes.js +28 -28
  30. package/lib/src/services/s3.service.js +42 -42
  31. package/lib/src/services/storage.service.js +311 -213
  32. package/lib/src/utils/cleanup-scheduler.js +91 -0
  33. package/lib/src/utils/errors.js +56 -56
  34. package/lib/src/utils/helpers.js +6 -6
  35. package/package.json +3 -5
  36. package/views/bucket.hbs +0 -5
  37. package/views/bucketadmin_move_modal.hbs +35 -35
  38. package/views/bucketadmin_preview_modal.hbs +108 -108
  39. package/views/bucketadmincontent.hbs +193 -193
  40. package/views/mbkbucket_info.hbs +134 -134
@@ -0,0 +1,58 @@
1
+ /**
2
+ * MBKBucket - Storage Provider Capabilities
3
+ * Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
4
+ * Licensed under the MIT License.
5
+ */
6
+
7
+ import { StorageUnsupportedOperationError } from "./errors.js";
8
+
9
+ export class StorageCapabilities {
10
+ constructor(flags = {}) {
11
+ this.multipart = Boolean(flags.multipart);
12
+ this.resumable = Boolean(flags.resumable ?? flags.multipart);
13
+ this.nativeFolders = Boolean(flags.nativeFolders);
14
+ this.ranges = Boolean(flags.ranges ?? true);
15
+ this.copy = Boolean(flags.copy);
16
+ this.move = Boolean(flags.move);
17
+ this.search = Boolean(flags.search);
18
+ this.directDownloadUrl = Boolean(flags.directDownloadUrl);
19
+ this.versioning = Boolean(flags.versioning);
20
+ this.publicUrls = Boolean(flags.publicUrls);
21
+ this.storageQuota = Boolean(flags.storageQuota);
22
+ this.deleteBatch = Boolean(flags.deleteBatch ?? true);
23
+ this.metadata = Boolean(flags.metadata ?? true);
24
+
25
+ Object.freeze(this);
26
+ }
27
+
28
+ has(capability) {
29
+ return Boolean(this[capability]);
30
+ }
31
+
32
+ assert(capability, operationName, providerType = 'storage provider') {
33
+ if (!this.has(capability)) {
34
+ const op = operationName || capability;
35
+ throw new StorageUnsupportedOperationError(
36
+ `Operation '${op}' is not supported by ${providerType} (missing capability: ${capability})`
37
+ );
38
+ }
39
+ }
40
+
41
+ toJSON() {
42
+ return {
43
+ multipart: this.multipart,
44
+ resumable: this.resumable,
45
+ nativeFolders: this.nativeFolders,
46
+ ranges: this.ranges,
47
+ copy: this.copy,
48
+ move: this.move,
49
+ search: this.search,
50
+ directDownloadUrl: this.directDownloadUrl,
51
+ versioning: this.versioning,
52
+ publicUrls: this.publicUrls,
53
+ storageQuota: this.storageQuota,
54
+ deleteBatch: this.deleteBatch,
55
+ metadata: this.metadata
56
+ };
57
+ }
58
+ }
@@ -5,11 +5,6 @@
5
5
  */
6
6
 
7
7
  export class StorageError extends Error {
8
- /**
9
- * @param {string} message
10
- * @param {number} [status=500]
11
- * @param {string} [code='INTERNAL_STORAGE_ERROR']
12
- */
13
8
  constructor(message, status = 500, code = 'INTERNAL_STORAGE_ERROR') {
14
9
  super(message);
15
10
  this.name = this.constructor.name;
@@ -15,17 +15,6 @@ export const GOOGLE_DRIVE_SCOPES = [
15
15
  'https://www.googleapis.com/auth/drive.metadata.readonly'
16
16
  ];
17
17
 
18
- /**
19
- * Generate Google OAuth 2.0 Authorization URL for user consent.
20
- * @param {object} options
21
- * @param {string} options.clientId
22
- * @param {string} options.redirectUri
23
- * @param {string} [options.state]
24
- * @param {string[]} [options.scopes]
25
- * @param {'consent'|'select_account'|'none'} [options.prompt='consent']
26
- * @param {'offline'|'online'} [options.accessType='offline']
27
- * @returns {string}
28
- */
29
18
  export function getGoogleAuthUrl({
30
19
  clientId,
31
20
  redirectUri,
@@ -51,15 +40,6 @@ export function getGoogleAuthUrl({
51
40
  return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
52
41
  }
53
42
 
54
- /**
55
- * Exchange an OAuth authorization code for tokens (access_token, refresh_token).
56
- * @param {object} options
57
- * @param {string} options.code
58
- * @param {string} options.clientId
59
- * @param {string} options.clientSecret
60
- * @param {string} options.redirectUri
61
- * @returns {Promise<{ access_token: string, refresh_token?: string, expires_in: number, token_type: string, scope: string, expiry_date: number }>}
62
- */
63
43
  export async function exchangeCodeForTokens({
64
44
  code,
65
45
  clientId,
@@ -94,14 +74,6 @@ export async function exchangeCodeForTokens({
94
74
  return { ...data, expiry_date };
95
75
  }
96
76
 
97
- /**
98
- * Refresh an expired access token using a refresh token.
99
- * @param {object} options
100
- * @param {string} options.clientId
101
- * @param {string} options.clientSecret
102
- * @param {string} options.refreshToken
103
- * @returns {Promise<{ access_token: string, expires_in: number, expiry_date: number, token_type: string }>}
104
- */
105
77
  export async function refreshGoogleAccessToken({
106
78
  clientId,
107
79
  clientSecret,
@@ -134,14 +106,6 @@ export async function refreshGoogleAccessToken({
134
106
  return { ...data, expiry_date };
135
107
  }
136
108
 
137
- /**
138
- * Generate an OAuth2 access token from a Google Service Account using JWT signing.
139
- * @param {object} options
140
- * @param {string} options.clientEmail
141
- * @param {string} options.privateKey
142
- * @param {string[]} [options.scopes]
143
- * @returns {Promise<{ access_token: string, expires_in: number, expiry_date: number }>}
144
- */
145
109
  export async function getServiceAccountAccessToken({
146
110
  clientEmail,
147
111
  privateKey,
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { Readable } from "node:stream";
8
8
  import { StorageProvider } from "./storage-provider.js";
9
+ import { StorageCapabilities } from "./capabilities.js";
9
10
  import { StorageFile, StorageFolder, StorageListResult } from "./models.js";
10
11
  import { StorageNotFoundError, StorageConflictError, StorageAccessDeniedError, StorageValidationError, StorageConfigError, StorageError } from "./errors.js";
11
12
  import { refreshGoogleAccessToken, getServiceAccountAccessToken } from "./gdrive-auth.js";
@@ -19,19 +20,49 @@ function escapeGdriveQuery(str = '') {
19
20
  }
20
21
 
21
22
  export class GoogleDriveStorageProvider extends StorageProvider {
22
- /**
23
- * @param {string} name - Connection identifier
24
- * @param {object} config - Configuration object
25
- */
26
23
  constructor(name, config = {}) {
27
24
  super(name, config);
28
25
  this.rootFolderId = config.folder_id || config.root_folder_id || 'root';
29
26
  this._accessToken = config.access_token || null;
30
27
  this._tokenExpiry = config.expiry_date || 0;
31
28
  this._folderCache = new Map();
32
- this._folderCache.set('', this.rootFolderId);
33
- this._folderCache.set('.', this.rootFolderId);
34
- this._folderCache.set('/', this.rootFolderId);
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
+ }
35
66
  }
36
67
 
37
68
  get type() {
@@ -39,16 +70,19 @@ export class GoogleDriveStorageProvider extends StorageProvider {
39
70
  }
40
71
 
41
72
  get capabilities() {
42
- return {
73
+ return new StorageCapabilities({
43
74
  multipart: false,
44
- presignedUrls: false,
75
+ resumable: false,
45
76
  nativeFolders: true,
46
77
  ranges: true,
47
78
  copy: true,
48
79
  move: true,
49
80
  search: true,
50
- directDownloadUrl: true
51
- };
81
+ directDownloadUrl: true,
82
+ versioning: false,
83
+ publicUrls: true,
84
+ metadata: true
85
+ });
52
86
  }
53
87
 
54
88
  async getAccessToken() {
@@ -102,29 +136,45 @@ export class GoogleDriveStorageProvider extends StorageProvider {
102
136
  }
103
137
 
104
138
  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
- }
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
+ }
120
168
 
121
- return response;
169
+ return response;
170
+ }
122
171
  }
123
172
 
124
173
  async _resolvePathToFolderId(path = '') {
125
174
  const cleanPath = trimSlashes(path);
126
175
  if (!cleanPath) return this.rootFolderId;
127
- if (this._folderCache.has(cleanPath)) return this._folderCache.get(cleanPath);
176
+ const cached = this._getFolderCache(cleanPath);
177
+ if (cached) return cached;
128
178
 
129
179
  const segments = cleanPath.split('/').filter(Boolean);
130
180
  let currentParentId = this.rootFolderId;
@@ -132,8 +182,9 @@ export class GoogleDriveStorageProvider extends StorageProvider {
132
182
 
133
183
  for (const segment of segments) {
134
184
  currentPath = currentPath ? `${currentPath}/${segment}` : segment;
135
- if (this._folderCache.has(currentPath)) {
136
- currentParentId = this._folderCache.get(currentPath);
185
+ const segCached = this._getFolderCache(currentPath);
186
+ if (segCached) {
187
+ currentParentId = segCached;
137
188
  continue;
138
189
  }
139
190
 
@@ -151,7 +202,7 @@ export class GoogleDriveStorageProvider extends StorageProvider {
151
202
  }
152
203
 
153
204
  currentParentId = data.files[0].id;
154
- this._folderCache.set(currentPath, currentParentId);
205
+ this._setFolderCache(currentPath, currentParentId);
155
206
  }
156
207
 
157
208
  return currentParentId;
@@ -160,7 +211,8 @@ export class GoogleDriveStorageProvider extends StorageProvider {
160
211
  async _ensureFolderPath(path = '') {
161
212
  const cleanPath = trimSlashes(path);
162
213
  if (!cleanPath) return this.rootFolderId;
163
- if (this._folderCache.has(cleanPath)) return this._folderCache.get(cleanPath);
214
+ const cached = this._getFolderCache(cleanPath);
215
+ if (cached) return cached;
164
216
 
165
217
  const segments = cleanPath.split('/').filter(Boolean);
166
218
  let currentParentId = this.rootFolderId;
@@ -168,8 +220,9 @@ export class GoogleDriveStorageProvider extends StorageProvider {
168
220
 
169
221
  for (const segment of segments) {
170
222
  currentPath = currentPath ? `${currentPath}/${segment}` : segment;
171
- if (this._folderCache.has(currentPath)) {
172
- currentParentId = this._folderCache.get(currentPath);
223
+ const segCached = this._getFolderCache(currentPath);
224
+ if (segCached) {
225
+ currentParentId = segCached;
173
226
  continue;
174
227
  }
175
228
 
@@ -202,7 +255,7 @@ export class GoogleDriveStorageProvider extends StorageProvider {
202
255
  currentParentId = createData.id;
203
256
  }
204
257
 
205
- this._folderCache.set(currentPath, currentParentId);
258
+ this._setFolderCache(currentPath, currentParentId);
206
259
  }
207
260
 
208
261
  return currentParentId;
@@ -304,7 +357,11 @@ export class GoogleDriveStorageProvider extends StorageProvider {
304
357
  for (const item of (data.files || [])) {
305
358
  if (item.mimeType === 'application/vnd.google-apps.folder') {
306
359
  const folderPrefix = `${prefixPath}${item.name}/`;
307
- commonPrefixes.push(new StorageFolder(folderPrefix, item.id));
360
+ commonPrefixes.push(new StorageFolder({
361
+ prefix: folderPrefix,
362
+ id: item.id,
363
+ provider: 'gdrive'
364
+ }));
308
365
  this._folderCache.set(trimSlashes(folderPrefix), item.id);
309
366
  } else {
310
367
  const itemKey = `${prefixPath}${item.name}`;
@@ -312,12 +369,16 @@ export class GoogleDriveStorageProvider extends StorageProvider {
312
369
  key: itemKey,
313
370
  size: parseInt(item.size || 0, 10),
314
371
  lastModified: item.modifiedTime || item.createdTime || Date.now(),
372
+ createdAt: item.createdTime,
315
373
  etag: item.md5Checksum ? `"${item.md5Checksum}"` : `"${item.id}"`,
316
374
  storageClass: 'GOOGLE_DRIVE',
375
+ provider: 'gdrive',
317
376
  id: item.id,
377
+ nativeId: item.id,
318
378
  mimeType: item.mimeType,
319
379
  webViewLink: item.webViewLink,
320
- webContentLink: item.webContentLink
380
+ webContentLink: item.webContentLink,
381
+ metadata: item.properties || {}
321
382
  }));
322
383
  }
323
384
  }
@@ -528,12 +589,7 @@ export class GoogleDriveStorageProvider extends StorageProvider {
528
589
  throw new StorageError(`Failed to delete Google Drive folder: ${data.error?.message || res.statusText}`);
529
590
  }
530
591
 
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
- }
592
+ this._invalidateFolderCache(cleanPrefix);
537
593
 
538
594
  return { deletedCount: 1, prefix: cleanPrefix, deletedAt: nowIso() };
539
595
  } catch (error) {
@@ -587,6 +643,7 @@ export class GoogleDriveStorageProvider extends StorageProvider {
587
643
  }
588
644
 
589
645
  async copyFile(sourceKey, destKey, _options = {}) {
646
+ this.assertCapability('copy', 'copyFile');
590
647
  try {
591
648
  const srcClean = trimLeadingSlashes(sourceKey);
592
649
  const dstClean = trimLeadingSlashes(destKey);
@@ -622,6 +679,7 @@ export class GoogleDriveStorageProvider extends StorageProvider {
622
679
  }
623
680
 
624
681
  async moveFile(sourceKey, destKey, _options = {}) {
682
+ this.assertCapability('move', 'moveFile');
625
683
  try {
626
684
  const srcClean = trimLeadingSlashes(sourceKey);
627
685
  const dstClean = trimLeadingSlashes(destKey);
@@ -5,10 +5,13 @@
5
5
  */
6
6
 
7
7
  export { StorageProvider } from "./storage-provider.js";
8
+ export { StorageCapabilities } from "./capabilities.js";
8
9
  export { S3StorageProvider } from "./s3-provider.js";
9
10
  export { GoogleDriveStorageProvider } from "./gdrive-provider.js";
10
- export { StorageFile, StorageFolder, StorageListResult } from "./models.js";
11
+ export { LocalStorageProvider } from "./local-provider.js";
12
+ export { StorageItem, StorageFile, StorageFolder, StorageListResult } from "./models.js";
11
13
  export { StorageError, StorageNotFoundError, StorageAccessDeniedError, StorageConflictError, StorageValidationError, StorageConfigError, StorageQuotaExceededError, StorageUnsupportedOperationError } from "./errors.js";
12
14
  export { PathStrategy } from "./path-strategy.js";
13
15
  export { getGoogleAuthUrl, exchangeCodeForTokens, refreshGoogleAccessToken, getServiceAccountAccessToken, GOOGLE_DRIVE_SCOPES } from "./gdrive-auth.js";
14
16
  export { StorageManager, storageManager } from "./storage-manager.js";
17
+