mbkbucket 1.5.1 → 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,6 +20,16 @@ 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
+ }
29
+
30
+ export function getAvailableBucketNames() {
31
+ return availableBucketNames;
32
+ }
21
33
 
22
34
  // Helper to get default bucket name
23
35
  function getDefaultBucketName() {
@@ -27,15 +39,34 @@ function getDefaultBucketName() {
27
39
  }
28
40
 
29
41
  // Second priority: use first bucket in BucketConnection
30
- const buckets = Object.keys(allBucketConfigs);
31
- if (buckets.length > 0) {
32
- return buckets[0];
42
+ if (availableBucketNames.length > 0) {
43
+ return availableBucketNames[0];
33
44
  }
34
45
 
35
46
  // Fallback: return null (will cause error in getBucketConfig)
36
47
  return null;
37
48
  }
38
49
 
50
+ export function resolveBucketName(bucketName) {
51
+ const candidate = (bucketName === undefined || bucketName === null)
52
+ ? getDefaultBucketName()
53
+ : String(bucketName).trim();
54
+
55
+ if (!candidate) {
56
+ throw new Error('No bucket selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.');
57
+ }
58
+
59
+ if (!hasBucketConfigs) {
60
+ throw new Error('BucketConnection environment variable is not set or empty. Please configure it with your bucket connections.');
61
+ }
62
+
63
+ if (!allBucketConfigs[candidate]) {
64
+ throw new Error(`Bucket '${candidate}' not found in BucketConnection. Available buckets: ${availableBucketNames.join(', ')}`);
65
+ }
66
+
67
+ return candidate;
68
+ }
69
+
39
70
  // (bucketClient singleton exported below, after cache setup)
40
71
 
41
72
  // Helper: get configured APP_NAME; prefer mbkautheVar, then env var
@@ -44,9 +75,12 @@ export function getAppName() {
44
75
  return (typeof app === 'string' && app.trim()) ? app.trim() : '';
45
76
  }
46
77
 
78
+ function isRootModeApp(appName) {
79
+ return String(appName || '').trim().toLowerCase() === 'portal';
80
+ }
81
+
47
82
  export function ensureKeyHasAppPrefix(key = '') {
48
83
  const app = getAppName();
49
- if (!app) throw new Error('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
50
84
  if (!key) throw new Error('Key is required');
51
85
 
52
86
  // Security: Detect path traversal attempts
@@ -60,8 +94,16 @@ export function ensureKeyHasAppPrefix(key = '') {
60
94
  }
61
95
 
62
96
  let cleaned = String(key).replace(/^\/+/, '');
97
+
98
+ // Special case: APP_NAME=portal means operate from bucket root.
99
+ if (isRootModeApp(app)) {
100
+ return cleaned;
101
+ }
102
+
103
+ if (!app) throw new Error('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
104
+
63
105
  // Collapse repeated app prefixes (app/app/... -> app/...)
64
- const appPrefixRegex = new RegExp(`^(?:${app}\/)+`);
106
+ const appPrefixRegex = new RegExp(`^(?:${escapeRegExp(app)}\/)+`);
65
107
  if (!cleaned.startsWith(`${app}/`)) {
66
108
  cleaned = `${app}/${cleaned}`;
67
109
  } else {
@@ -73,7 +115,6 @@ export function ensureKeyHasAppPrefix(key = '') {
73
115
 
74
116
  export function ensurePrefix(prefix = '') {
75
117
  const app = getAppName();
76
- if (!app) throw new Error('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
77
118
 
78
119
  // Security: Detect path traversal attempts
79
120
  if (prefix && (prefix.includes('../') || prefix.includes('..\\') || /\.\.[\\/]/.test(prefix))) {
@@ -87,6 +128,13 @@ export function ensurePrefix(prefix = '') {
87
128
 
88
129
  // Only strip LEADING slashes. Preserve trailing slashes for directory listing.
89
130
  let p = String(prefix || '').replace(/^\/+/, '');
131
+
132
+ // Special case: APP_NAME=portal means operate from bucket root.
133
+ if (isRootModeApp(app)) {
134
+ return p;
135
+ }
136
+
137
+ if (!app) throw new Error('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
90
138
 
91
139
  if (!p) return `${app}`;
92
140
 
@@ -96,7 +144,7 @@ export function ensurePrefix(prefix = '') {
96
144
 
97
145
  if (p.startsWith(`${app}/`)) {
98
146
  // collapse repeated app/ sequences
99
- const appPrefixRegex = new RegExp(`^(?:${app}\/)+`);
147
+ const appPrefixRegex = new RegExp(`^(?:${escapeRegExp(app)}\/)+`);
100
148
  return p.replace(appPrefixRegex, `${app}/`);
101
149
  }
102
150
  return `${app}/${p}`;
@@ -110,19 +158,13 @@ const _clientCache = new Map();
110
158
  const _configCache = new Map();
111
159
 
112
160
  export function getBucketConfig(bucketName) {
113
- // Resolve default bucket name if not provided
114
- if (!bucketName) {
115
- bucketName = getDefaultBucketName();
116
- if (!bucketName) {
117
- throw new Error('No bucket name provided and no default bucket configured. Please set mbkautheVar.bucket or configure BucketConnection.');
118
- }
119
- }
161
+ bucketName = resolveBucketName(bucketName);
120
162
 
121
163
  const cacheKey = bucketName;
122
164
  if (_configCache.has(cacheKey)) return _configCache.get(cacheKey);
123
165
 
124
166
  // Look up bucket config from BucketConnection JSON
125
- if (!allBucketConfigs || Object.keys(allBucketConfigs).length === 0) {
167
+ if (!hasBucketConfigs) {
126
168
  console.error(`[mbkbucket] ❌ BucketConnection is not configured or failed to parse`);
127
169
  console.error(`[mbkbucket] Please set BucketConnection in your .env file with this format:`);
128
170
  console.error(`[mbkbucket] BucketConnection={"r2":{"BUCKET_NAME":"...","ACCESS_KEY_ID":"...","SECRET_ACCESS_KEY":"...","ENDPOINT":"https://..."}}`);
@@ -135,7 +177,7 @@ export function getBucketConfig(bucketName) {
135
177
  const cfg = allBucketConfigs[bucketName];
136
178
 
137
179
  if (!cfg) {
138
- 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(', ')}`);
139
181
  }
140
182
 
141
183
  _configCache.set(cacheKey, cfg);
@@ -144,10 +186,7 @@ export function getBucketConfig(bucketName) {
144
186
 
145
187
  // Function to get S3 client for specific bucket — returns cached singleton
146
188
  export function getBucketClient(bucketName) {
147
- // Resolve default bucket name if not provided
148
- if (!bucketName) {
149
- bucketName = getDefaultBucketName();
150
- }
189
+ bucketName = resolveBucketName(bucketName);
151
190
 
152
191
  if (_clientCache.has(bucketName)) return _clientCache.get(bucketName);
153
192
 
@@ -218,10 +257,10 @@ async function checkHealth() {
218
257
  // Quick diagnostics at startup
219
258
  (async () => {
220
259
  try {
221
- 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 });
222
261
  const health = await checkHealth();
223
262
  if (health.status === 'healthy') {
224
- console.log(`[mbkbucket] Connected to bucket: ${health.bucket} (${health.responseTime}ms)`);
263
+ debugS3('Connected to bucket: %s (%sms)', health.bucket, health.responseTime);
225
264
  } else {
226
265
  console.error(`[mbkbucket] S3 connection failed:`, health.error);
227
266
  }
@@ -254,11 +293,12 @@ export async function uploadFile(key, fileBuffer, contentType, options = {}) {
254
293
  const config = getBucketConfig(bucketName);
255
294
  const bucket = config.BUCKET_NAME;
256
295
 
257
- console.log(`[mbkbucket] Uploading file to bucket=${bucket} key=${keyToUpload}`);
296
+ debugS3('Uploading file to bucket=%s key=%s', bucket, keyToUpload);
258
297
 
259
298
  // Add default metadata (supports metadata)
299
+ const uploadedAt = new Date().toISOString();
260
300
  const defaultMetadata = {
261
- 'uploaded-at': new Date().toISOString(),
301
+ 'uploaded-at': uploadedAt,
262
302
  'file-size': fileBuffer.length.toString(),
263
303
  'upload-source': 'web-portal',
264
304
  ...metadata
@@ -289,7 +329,7 @@ export async function uploadFile(key, fileBuffer, contentType, options = {}) {
289
329
  fileSize: fileBuffer.length,
290
330
  key: keyToUpload,
291
331
  contentType,
292
- uploadedAt: new Date().toISOString()
332
+ uploadedAt
293
333
  };
294
334
  } catch (error) {
295
335
  console.error(`Upload failed for key ${key}:`, error);
@@ -329,7 +369,7 @@ export async function downloadFile(key, options = {}) {
329
369
 
330
370
  // Log range requests for debugging
331
371
  if (range) {
332
- console.log(`[mbkbucket] Range request for ${keyToDownload}: ${range}`);
372
+ debugS3('Range request for %s: %s', keyToDownload, range);
333
373
  }
334
374
 
335
375
  const command = new GetObjectCommand({
@@ -342,9 +382,7 @@ export async function downloadFile(key, options = {}) {
342
382
  ...(responseContentType && { ResponseContentType: responseContentType }),
343
383
  });
344
384
 
345
- const startTime = Date.now();
346
385
  const result = await client.send(command);
347
- const downloadTime = Date.now() - startTime;
348
386
 
349
387
  return {
350
388
  ...result,
@@ -414,6 +452,8 @@ export async function deleteFiles(keys, bucketName) {
414
452
  // S3 allows max 1000 objects per delete request
415
453
  const maxBatchSize = 1000;
416
454
  const results = [];
455
+ let deletedCount = 0;
456
+ const errors = [];
417
457
 
418
458
  for (let i = 0; i < keys.length; i += maxBatchSize) {
419
459
  const batch = keys.slice(i, i + maxBatchSize).map(k => ({ Key: ensureKeyHasAppPrefix(k) }));
@@ -428,12 +468,14 @@ export async function deleteFiles(keys, bucketName) {
428
468
 
429
469
  const result = await client.send(command);
430
470
  results.push(result);
471
+ deletedCount += result.Deleted?.length || 0;
472
+ if (result.Errors?.length) errors.push(...result.Errors);
431
473
  }
432
474
 
433
475
  return {
434
476
  results,
435
- deletedCount: results.reduce((acc, result) => acc + (result.Deleted?.length || 0), 0),
436
- errors: results.reduce((acc, result) => acc.concat(result.Errors || []), []),
477
+ deletedCount,
478
+ errors,
437
479
  deletedAt: new Date().toISOString()
438
480
  };
439
481
  } catch (error) {
@@ -568,19 +610,21 @@ export async function getFileMetadata(key, bucketName) {
568
610
  });
569
611
 
570
612
  const result = await client.send(command);
613
+ const queriedAt = new Date().toISOString();
571
614
 
572
615
  return {
573
616
  ...result,
574
617
  key: keyToCheck,
575
618
  exists: true,
576
- queriedAt: new Date().toISOString()
619
+ queriedAt
577
620
  };
578
621
  } catch (error) {
622
+ const queriedAt = new Date().toISOString();
579
623
  if (error.name === 'NotFound' || error.name === 'NoSuchKey') {
580
624
  return {
581
625
  key,
582
626
  exists: false,
583
- queriedAt: new Date().toISOString()
627
+ queriedAt
584
628
  };
585
629
  }
586
630
 
@@ -619,17 +663,18 @@ export { checkHealth };
619
663
  /**
620
664
  * Initiate a multipart upload. Returns the S3 UploadId.
621
665
  */
622
- export async function createMultipartUpload(key, contentType = 'application/octet-stream', metadata = {}) {
666
+ export async function createMultipartUpload(key, contentType = 'application/octet-stream', metadata = {}, bucketName) {
623
667
  const keyToUse = ensureKeyHasAppPrefix(key);
624
- const cfg = getBucketConfig();
625
- const client = getBucketClient();
668
+ const cfg = getBucketConfig(bucketName);
669
+ const client = getBucketClient(bucketName);
670
+ const uploadedAt = new Date().toISOString();
626
671
  const command = new CreateMultipartUploadCommand({
627
672
  Bucket: cfg.BUCKET_NAME,
628
673
  Key: keyToUse,
629
674
  ContentType: contentType,
630
675
  Metadata: {
631
676
  'upload-source': 'web-portal',
632
- 'uploaded-at': new Date().toISOString(),
677
+ 'uploaded-at': uploadedAt,
633
678
  ...metadata
634
679
  }
635
680
  });
@@ -641,10 +686,10 @@ export async function createMultipartUpload(key, contentType = 'application/octe
641
686
  * Upload a single part. partNumber is 1-based.
642
687
  * Returns the ETag needed for CompleteMultipartUpload.
643
688
  */
644
- export async function uploadPart(key, uploadId, partNumber, buffer) {
689
+ export async function uploadPart(key, uploadId, partNumber, buffer, bucketName) {
645
690
  const keyToUse = ensureKeyHasAppPrefix(key);
646
- const cfg = getBucketConfig();
647
- const client = getBucketClient();
691
+ const cfg = getBucketConfig(bucketName);
692
+ const client = getBucketClient(bucketName);
648
693
  const command = new UploadPartCommand({
649
694
  Bucket: cfg.BUCKET_NAME,
650
695
  Key: keyToUse,
@@ -661,10 +706,10 @@ export async function uploadPart(key, uploadId, partNumber, buffer) {
661
706
  * Complete a multipart upload.
662
707
  * parts must be an array of { partNumber, ETag } sorted ascending by partNumber.
663
708
  */
664
- export async function completeMultipartUpload(key, uploadId, parts) {
709
+ export async function completeMultipartUpload(key, uploadId, parts, bucketName) {
665
710
  const keyToUse = ensureKeyHasAppPrefix(key);
666
- const cfg = getBucketConfig();
667
- const client = getBucketClient();
711
+ const cfg = getBucketConfig(bucketName);
712
+ const client = getBucketClient(bucketName);
668
713
  const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber);
669
714
  const command = new CompleteMultipartUploadCommand({
670
715
  Bucket: cfg.BUCKET_NAME,
@@ -681,10 +726,10 @@ export async function completeMultipartUpload(key, uploadId, parts) {
681
726
  /**
682
727
  * Abort an in-progress multipart upload, freeing all stored parts.
683
728
  */
684
- export async function abortMultipartUpload(key, uploadId) {
729
+ export async function abortMultipartUpload(key, uploadId, bucketName) {
685
730
  const keyToUse = ensureKeyHasAppPrefix(key);
686
- const cfg = getBucketConfig();
687
- const client = getBucketClient();
731
+ const cfg = getBucketConfig(bucketName);
732
+ const client = getBucketClient(bucketName);
688
733
  const command = new AbortMultipartUploadCommand({
689
734
  Bucket: cfg.BUCKET_NAME,
690
735
  Key: keyToUse,
@@ -695,26 +740,26 @@ export async function abortMultipartUpload(key, uploadId) {
695
740
  }
696
741
 
697
742
  // Generate signed URL for temporary access
698
- export async function generateSignedUrl(key, operation = 'getObject', expiresIn = 3600) {
743
+ export async function generateSignedUrl(key, operation = 'getObject', expiresIn = 3600, bucketName) {
699
744
  try {
700
745
  const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
701
746
  const keyToUse = ensureKeyHasAppPrefix(key);
702
747
 
703
748
  let command;
704
- const cfg = getBucketConfig();
705
- const bucketName = cfg.BUCKET_NAME;
749
+ const cfg = getBucketConfig(bucketName);
750
+ const resolvedBucketName = cfg.BUCKET_NAME;
706
751
  switch (operation) {
707
752
  case 'getObject':
708
- command = new GetObjectCommand({ Bucket: bucketName, Key: keyToUse });
753
+ command = new GetObjectCommand({ Bucket: resolvedBucketName, Key: keyToUse });
709
754
  break;
710
755
  case 'putObject':
711
- command = new PutObjectCommand({ Bucket: bucketName, Key: keyToUse });
756
+ command = new PutObjectCommand({ Bucket: resolvedBucketName, Key: keyToUse });
712
757
  break;
713
758
  default:
714
759
  throw new Error(`Unsupported operation: ${operation}`);
715
760
  }
716
761
 
717
- const signedUrl = await getSignedUrl(bucketClient, command, { expiresIn });
762
+ const signedUrl = await getSignedUrl(getBucketClient(bucketName), command, { expiresIn });
718
763
 
719
764
  return {
720
765
  url: signedUrl,
@@ -742,7 +787,7 @@ export async function generateSignedUrl(key, operation = 'getObject', expiresIn
742
787
  */
743
788
  export async function listIncompleteMultipartUploads(prefix = '', bucketName) {
744
789
  try {
745
- const effectivePrefix = prefix ? ensurePrefix(prefix) : getAppName();
790
+ const effectivePrefix = ensurePrefix(prefix || '');
746
791
  const cfg = getBucketConfig(bucketName);
747
792
  const client = getBucketClient(bucketName);
748
793
 
@@ -771,7 +816,7 @@ export async function cleanupIncompleteMultipartUploads(olderThanDays = 7, prefi
771
816
  const uploads = await listIncompleteMultipartUploads(prefix, bucketName);
772
817
 
773
818
  if (!uploads.length) {
774
- console.log('[mbkbucket] No incomplete multipart uploads found');
819
+ debugS3('No incomplete multipart uploads found');
775
820
  return { abortedCount: 0, uploads: [] };
776
821
  }
777
822
 
@@ -783,22 +828,22 @@ export async function cleanupIncompleteMultipartUploads(olderThanDays = 7, prefi
783
828
  });
784
829
 
785
830
  if (!uploadsToAbort.length) {
786
- console.log(`[mbkbucket] No incomplete uploads older than ${olderThanDays} days`);
831
+ debugS3('No incomplete uploads older than %s days', olderThanDays);
787
832
  return { abortedCount: 0, uploads: [] };
788
833
  }
789
834
 
790
- console.log(`[mbkbucket] Found ${uploadsToAbort.length} incomplete uploads to clean up`);
835
+ debugS3('Found %s incomplete uploads to clean up', uploadsToAbort.length);
791
836
 
792
837
  const aborted = [];
793
838
  for (const upload of uploadsToAbort) {
794
839
  try {
795
- await abortMultipartUpload(upload.Key, upload.UploadId);
840
+ await abortMultipartUpload(upload.Key, upload.UploadId, bucketName);
796
841
  aborted.push({
797
842
  key: upload.Key,
798
843
  uploadId: upload.UploadId,
799
844
  initiated: upload.Initiated
800
845
  });
801
- console.log(`[mbkbucket] Aborted incomplete upload: ${upload.Key} (initiated: ${upload.Initiated})`);
846
+ debugS3('Aborted incomplete upload: %s (initiated: %s)', upload.Key, upload.Initiated);
802
847
  } catch (err) {
803
848
  console.error(`[mbkbucket] Failed to abort upload ${upload.Key}:`, err);
804
849
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mbkbucket",
3
- "version": "1.5.1",
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",
13
- "test": "echo \\\"Error: no test specified\\\" && exit 1"
12
+ "dev": "cross-env NODE_ENV=dev nodemon index.js",
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
+ }