mbkbucket 2.0.0 → 2.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.
package/lib/s3.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { S3Client, ListObjectsV2Command, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, HeadObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, ListMultipartUploadsCommand } from '@aws-sdk/client-s3';
2
2
  import dotenv from "dotenv";
3
3
  import { mbkautheVar } from "mbkauthe";
4
+ import { createLogger } from "./debug.js";
4
5
 
5
6
  dotenv.config();
7
+ const debugS3 = createLogger('s3');
6
8
 
7
9
  function parseJsonEnv(envVar, fallback = {}) {
8
10
  if (!envVar) return fallback;
@@ -18,9 +20,15 @@ function parseJsonEnv(envVar, fallback = {}) {
18
20
 
19
21
  // Parse BucketConnection JSON that contains all bucket configurations
20
22
  const allBucketConfigs = parseJsonEnv(process.env.BucketConnection, {});
23
+ const availableBucketNames = Object.freeze(Object.keys(allBucketConfigs || {}));
24
+ const hasBucketConfigs = availableBucketNames.length > 0;
25
+
26
+ function escapeRegExp(value) {
27
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
28
+ }
21
29
 
22
30
  export function getAvailableBucketNames() {
23
- return Object.keys(allBucketConfigs || {});
31
+ return availableBucketNames;
24
32
  }
25
33
 
26
34
  // Helper to get default bucket name
@@ -31,9 +39,8 @@ function getDefaultBucketName() {
31
39
  }
32
40
 
33
41
  // Second priority: use first bucket in BucketConnection
34
- const buckets = Object.keys(allBucketConfigs);
35
- if (buckets.length > 0) {
36
- return buckets[0];
42
+ if (availableBucketNames.length > 0) {
43
+ return availableBucketNames[0];
37
44
  }
38
45
 
39
46
  // Fallback: return null (will cause error in getBucketConfig)
@@ -49,12 +56,12 @@ export function resolveBucketName(bucketName) {
49
56
  throw new Error('No bucket selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.');
50
57
  }
51
58
 
52
- if (!allBucketConfigs || Object.keys(allBucketConfigs).length === 0) {
59
+ if (!hasBucketConfigs) {
53
60
  throw new Error('BucketConnection environment variable is not set or empty. Please configure it with your bucket connections.');
54
61
  }
55
62
 
56
63
  if (!allBucketConfigs[candidate]) {
57
- throw new Error(`Bucket '${candidate}' not found in BucketConnection. Available buckets: ${Object.keys(allBucketConfigs).join(', ')}`);
64
+ throw new Error(`Bucket '${candidate}' not found in BucketConnection. Available buckets: ${availableBucketNames.join(', ')}`);
58
65
  }
59
66
 
60
67
  return candidate;
@@ -96,7 +103,7 @@ export function ensureKeyHasAppPrefix(key = '') {
96
103
  if (!app) throw new Error('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
97
104
 
98
105
  // Collapse repeated app prefixes (app/app/... -> app/...)
99
- const appPrefixRegex = new RegExp(`^(?:${app}\/)+`);
106
+ const appPrefixRegex = new RegExp(`^(?:${escapeRegExp(app)}\/)+`);
100
107
  if (!cleaned.startsWith(`${app}/`)) {
101
108
  cleaned = `${app}/${cleaned}`;
102
109
  } else {
@@ -137,7 +144,7 @@ export function ensurePrefix(prefix = '') {
137
144
 
138
145
  if (p.startsWith(`${app}/`)) {
139
146
  // collapse repeated app/ sequences
140
- const appPrefixRegex = new RegExp(`^(?:${app}\/)+`);
147
+ const appPrefixRegex = new RegExp(`^(?:${escapeRegExp(app)}\/)+`);
141
148
  return p.replace(appPrefixRegex, `${app}/`);
142
149
  }
143
150
  return `${app}/${p}`;
@@ -157,7 +164,7 @@ export function getBucketConfig(bucketName) {
157
164
  if (_configCache.has(cacheKey)) return _configCache.get(cacheKey);
158
165
 
159
166
  // Look up bucket config from BucketConnection JSON
160
- if (!allBucketConfigs || Object.keys(allBucketConfigs).length === 0) {
167
+ if (!hasBucketConfigs) {
161
168
  console.error(`[mbkbucket] ❌ BucketConnection is not configured or failed to parse`);
162
169
  console.error(`[mbkbucket] Please set BucketConnection in your .env file with this format:`);
163
170
  console.error(`[mbkbucket] BucketConnection={"r2":{"BUCKET_NAME":"...","ACCESS_KEY_ID":"...","SECRET_ACCESS_KEY":"...","ENDPOINT":"https://..."}}`);
@@ -170,7 +177,7 @@ export function getBucketConfig(bucketName) {
170
177
  const cfg = allBucketConfigs[bucketName];
171
178
 
172
179
  if (!cfg) {
173
- throw new Error(`Bucket '${bucketName}' not found in BucketConnection. Available buckets: ${Object.keys(allBucketConfigs).join(', ')}`);
180
+ throw new Error(`Bucket '${bucketName}' not found in BucketConnection. Available buckets: ${availableBucketNames.join(', ')}`);
174
181
  }
175
182
 
176
183
  _configCache.set(cacheKey, cfg);
@@ -250,10 +257,10 @@ async function checkHealth() {
250
257
  // Quick diagnostics at startup
251
258
  (async () => {
252
259
  try {
253
- console.log('[mbkbucket] mbkautheVar snapshot:', { APP_NAME: mbkautheVar?.APP_NAME, bucket: mbkautheVar?.bucket });
260
+ debugS3('mbkautheVar snapshot: %O', { APP_NAME: mbkautheVar?.APP_NAME, bucket: mbkautheVar?.bucket });
254
261
  const health = await checkHealth();
255
262
  if (health.status === 'healthy') {
256
- console.log(`[mbkbucket] Connected to bucket: ${health.bucket} (${health.responseTime}ms)`);
263
+ debugS3('Connected to bucket: %s (%sms)', health.bucket, health.responseTime);
257
264
  } else {
258
265
  console.error(`[mbkbucket] S3 connection failed:`, health.error);
259
266
  }
@@ -286,11 +293,12 @@ export async function uploadFile(key, fileBuffer, contentType, options = {}) {
286
293
  const config = getBucketConfig(bucketName);
287
294
  const bucket = config.BUCKET_NAME;
288
295
 
289
- console.log(`[mbkbucket] Uploading file to bucket=${bucket} key=${keyToUpload}`);
296
+ debugS3('Uploading file to bucket=%s key=%s', bucket, keyToUpload);
290
297
 
291
298
  // Add default metadata (supports metadata)
299
+ const uploadedAt = new Date().toISOString();
292
300
  const defaultMetadata = {
293
- 'uploaded-at': new Date().toISOString(),
301
+ 'uploaded-at': uploadedAt,
294
302
  'file-size': fileBuffer.length.toString(),
295
303
  'upload-source': 'web-portal',
296
304
  ...metadata
@@ -321,7 +329,7 @@ export async function uploadFile(key, fileBuffer, contentType, options = {}) {
321
329
  fileSize: fileBuffer.length,
322
330
  key: keyToUpload,
323
331
  contentType,
324
- uploadedAt: new Date().toISOString()
332
+ uploadedAt
325
333
  };
326
334
  } catch (error) {
327
335
  console.error(`Upload failed for key ${key}:`, error);
@@ -361,7 +369,7 @@ export async function downloadFile(key, options = {}) {
361
369
 
362
370
  // Log range requests for debugging
363
371
  if (range) {
364
- console.log(`[mbkbucket] Range request for ${keyToDownload}: ${range}`);
372
+ debugS3('Range request for %s: %s', keyToDownload, range);
365
373
  }
366
374
 
367
375
  const command = new GetObjectCommand({
@@ -374,9 +382,7 @@ export async function downloadFile(key, options = {}) {
374
382
  ...(responseContentType && { ResponseContentType: responseContentType }),
375
383
  });
376
384
 
377
- const startTime = Date.now();
378
385
  const result = await client.send(command);
379
- const downloadTime = Date.now() - startTime;
380
386
 
381
387
  return {
382
388
  ...result,
@@ -446,6 +452,8 @@ export async function deleteFiles(keys, bucketName) {
446
452
  // S3 allows max 1000 objects per delete request
447
453
  const maxBatchSize = 1000;
448
454
  const results = [];
455
+ let deletedCount = 0;
456
+ const errors = [];
449
457
 
450
458
  for (let i = 0; i < keys.length; i += maxBatchSize) {
451
459
  const batch = keys.slice(i, i + maxBatchSize).map(k => ({ Key: ensureKeyHasAppPrefix(k) }));
@@ -460,12 +468,14 @@ export async function deleteFiles(keys, bucketName) {
460
468
 
461
469
  const result = await client.send(command);
462
470
  results.push(result);
471
+ deletedCount += result.Deleted?.length || 0;
472
+ if (result.Errors?.length) errors.push(...result.Errors);
463
473
  }
464
474
 
465
475
  return {
466
476
  results,
467
- deletedCount: results.reduce((acc, result) => acc + (result.Deleted?.length || 0), 0),
468
- errors: results.reduce((acc, result) => acc.concat(result.Errors || []), []),
477
+ deletedCount,
478
+ errors,
469
479
  deletedAt: new Date().toISOString()
470
480
  };
471
481
  } catch (error) {
@@ -600,19 +610,21 @@ export async function getFileMetadata(key, bucketName) {
600
610
  });
601
611
 
602
612
  const result = await client.send(command);
613
+ const queriedAt = new Date().toISOString();
603
614
 
604
615
  return {
605
616
  ...result,
606
617
  key: keyToCheck,
607
618
  exists: true,
608
- queriedAt: new Date().toISOString()
619
+ queriedAt
609
620
  };
610
621
  } catch (error) {
622
+ const queriedAt = new Date().toISOString();
611
623
  if (error.name === 'NotFound' || error.name === 'NoSuchKey') {
612
624
  return {
613
625
  key,
614
626
  exists: false,
615
- queriedAt: new Date().toISOString()
627
+ queriedAt
616
628
  };
617
629
  }
618
630
 
@@ -655,13 +667,14 @@ export async function createMultipartUpload(key, contentType = 'application/octe
655
667
  const keyToUse = ensureKeyHasAppPrefix(key);
656
668
  const cfg = getBucketConfig(bucketName);
657
669
  const client = getBucketClient(bucketName);
670
+ const uploadedAt = new Date().toISOString();
658
671
  const command = new CreateMultipartUploadCommand({
659
672
  Bucket: cfg.BUCKET_NAME,
660
673
  Key: keyToUse,
661
674
  ContentType: contentType,
662
675
  Metadata: {
663
676
  'upload-source': 'web-portal',
664
- 'uploaded-at': new Date().toISOString(),
677
+ 'uploaded-at': uploadedAt,
665
678
  ...metadata
666
679
  }
667
680
  });
@@ -803,7 +816,7 @@ export async function cleanupIncompleteMultipartUploads(olderThanDays = 7, prefi
803
816
  const uploads = await listIncompleteMultipartUploads(prefix, bucketName);
804
817
 
805
818
  if (!uploads.length) {
806
- console.log('[mbkbucket] No incomplete multipart uploads found');
819
+ debugS3('No incomplete multipart uploads found');
807
820
  return { abortedCount: 0, uploads: [] };
808
821
  }
809
822
 
@@ -815,22 +828,22 @@ export async function cleanupIncompleteMultipartUploads(olderThanDays = 7, prefi
815
828
  });
816
829
 
817
830
  if (!uploadsToAbort.length) {
818
- console.log(`[mbkbucket] No incomplete uploads older than ${olderThanDays} days`);
831
+ debugS3('No incomplete uploads older than %s days', olderThanDays);
819
832
  return { abortedCount: 0, uploads: [] };
820
833
  }
821
834
 
822
- console.log(`[mbkbucket] Found ${uploadsToAbort.length} incomplete uploads to clean up`);
835
+ debugS3('Found %s incomplete uploads to clean up', uploadsToAbort.length);
823
836
 
824
837
  const aborted = [];
825
838
  for (const upload of uploadsToAbort) {
826
839
  try {
827
- await abortMultipartUpload(upload.Key, upload.UploadId);
840
+ await abortMultipartUpload(upload.Key, upload.UploadId, bucketName);
828
841
  aborted.push({
829
842
  key: upload.Key,
830
843
  uploadId: upload.UploadId,
831
844
  initiated: upload.Initiated
832
845
  });
833
- console.log(`[mbkbucket] Aborted incomplete upload: ${upload.Key} (initiated: ${upload.Initiated})`);
846
+ debugS3('Aborted incomplete upload: %s (initiated: %s)', upload.Key, upload.Initiated);
834
847
  } catch (err) {
835
848
  console.error(`[mbkbucket] Failed to abort upload ${upload.Key}:`, err);
836
849
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mbkbucket",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Flexible S3/R2 bucket management library and Express router for mbktech.org applications, providing upload, download, listing, and multipart utilities with built-in configuration validation and health checks",
5
5
  "author": "Muhammad Bin Khalid",
6
6
  "homepage": "https://github.com/MIbnEKhalid/mbkbucket#readme",
@@ -9,18 +9,19 @@
9
9
  "type": "module",
10
10
  "types": "index.d.ts",
11
11
  "scripts": {
12
- "dev": "cross-env mbkbucket_test=dev nodemon index.js",
12
+ "dev": "cross-env NODE_ENV=dev nodemon index.js",
13
13
  "test": "node --test"
14
14
  },
15
15
  "dependencies": {
16
16
  "@aws-sdk/client-s3": "^3.968.0",
17
17
  "@aws-sdk/s3-request-presigner": "^3.1000.0",
18
18
  "compression": "^1.8.0",
19
+ "debug": "^2.6.9",
19
20
  "dotenv": "^16.6.1",
20
21
  "express": "^4.22.1",
21
22
  "express-handlebars": "^8.0.3",
22
23
  "express-rate-limit": "^7.0.1",
23
- "mbkauthe": "latest",
24
+ "mbkauthe": "5.0.4",
24
25
  "multer": "^2.0.2",
25
26
  "pg": "^8.16.3"
26
27
  },
@@ -49,4 +50,4 @@
49
50
  "mbkauthe",
50
51
  "mbktech.org"
51
52
  ]
52
- }
53
+ }
@@ -731,9 +731,18 @@
731
731
  align-items: center;
732
732
  gap: 0.5rem;
733
733
  font-size: 0.9rem;
734
+ min-width: 0;
735
+ }
736
+
737
+ .breadcrumb-nav .breadcrumb-item::before {
738
+ display: none !important;
739
+ content: none !important;
734
740
  }
735
741
 
736
742
  .breadcrumb-link {
743
+ background: transparent;
744
+ border: 0;
745
+ padding: 0;
737
746
  color: var(--primary-color-ml);
738
747
  cursor: pointer;
739
748
  transition: all 0.2s ease;
@@ -754,6 +763,8 @@
754
763
  .breadcrumb-current {
755
764
  color: var(--dark-color-ml);
756
765
  font-weight: 600;
766
+ min-width: 0;
767
+ overflow-wrap: anywhere;
757
768
  }
758
769
 
759
770
  .file-icon {
@@ -2048,6 +2059,19 @@
2048
2059
  .breadcrumb-nav {
2049
2060
  padding: 0.5rem 1rem;
2050
2061
  font-size: 0.85rem;
2062
+ gap: 0.35rem;
2063
+ overflow: visible;
2064
+ }
2065
+
2066
+ .breadcrumb-item {
2067
+ max-width: 100%;
2068
+ }
2069
+
2070
+ .breadcrumb-current,
2071
+ .breadcrumb-link {
2072
+ max-width: calc(100vw - 4rem);
2073
+ white-space: normal;
2074
+ overflow-wrap: anywhere;
2051
2075
  }
2052
2076
 
2053
2077
  .file-size {
@@ -2172,4 +2196,103 @@
2172
2196
  .dtm {
2173
2197
  display: block;
2174
2198
  }
2175
- }
2199
+ }
2200
+
2201
+ .table-cell-center {
2202
+ text-align: center;
2203
+ }
2204
+
2205
+ .table-select-col {
2206
+ width: 48px;
2207
+ text-align: center;
2208
+ }
2209
+
2210
+ .file-name-cell,
2211
+ .file-name-heading {
2212
+ padding-left: 1.5rem;
2213
+ }
2214
+
2215
+ .file-row-main {
2216
+ display: flex;
2217
+ align-items: center;
2218
+ }
2219
+
2220
+ .folder-name {
2221
+ font-weight: 700;
2222
+ }
2223
+
2224
+ .folder-leading-icon {
2225
+ margin-right: 0.5rem;
2226
+ color: #f59e0b;
2227
+ }
2228
+
2229
+ .muted-cell-value {
2230
+ color: var(--gray-color-ml);
2231
+ }
2232
+
2233
+ .path-display-icon {
2234
+ margin-right: 4px;
2235
+ opacity: 0.5;
2236
+ }
2237
+
2238
+ .copy-btn {
2239
+ color: #fff;
2240
+ }
2241
+
2242
+ .view-btn.is-disabled {
2243
+ opacity: 0.5;
2244
+ }
2245
+
2246
+ .sortable-heading {
2247
+ cursor: pointer;
2248
+ }
2249
+
2250
+ .bulk-actions-controls {
2251
+ float: right;
2252
+ }
2253
+
2254
+ .toast-icon {
2255
+ font-size: 1.25rem;
2256
+ }
2257
+
2258
+ .toast-message {
2259
+ flex: 1;
2260
+ }
2261
+
2262
+ .empty-state-error-icon {
2263
+ color: var(--danger-color-ml);
2264
+ }
2265
+
2266
+ .empty-state-folder-icon {
2267
+ font-size: 4rem;
2268
+ color: #cbd5e0;
2269
+ }
2270
+
2271
+ .upload-progress-cell {
2272
+ min-width: 220px;
2273
+ }
2274
+
2275
+ .upload-progress-track {
2276
+ height: 8px;
2277
+ margin-bottom: 6px;
2278
+ }
2279
+
2280
+ .upload-progress-bar {
2281
+ height: 8px;
2282
+ }
2283
+
2284
+ .progress-bar-empty {
2285
+ width: 0%;
2286
+ }
2287
+
2288
+ .is-hidden {
2289
+ display: none !important;
2290
+ }
2291
+
2292
+ .bucket-switcher-status:not(.is-hidden) {
2293
+ display: inline;
2294
+ }
2295
+
2296
+ .file-list-card-body {
2297
+ padding: 0;
2298
+ }