mbkbucket 1.5.0 → 2.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.
@@ -1,6 +1,6 @@
1
1
  import express from "express";
2
2
  import { validateSessionAndRole } from "mbkauthe";
3
- import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, generateSignedUrl } from "../s3.js";
3
+ import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, generateSignedUrl, resolveBucketName } from "../s3.js";
4
4
  import { mbkbucketVar } from "../config/index.js";
5
5
  import rateLimit from "express-rate-limit";
6
6
 
@@ -63,9 +63,44 @@ function pviewSecurity(req, res, next) {
63
63
 
64
64
  const router = express.Router();
65
65
 
66
+ function classifyViewError(error, fallbackMessage = 'Request failed') {
67
+ const message = String(error?.message || fallbackMessage);
68
+ const lower = message.toLowerCase();
69
+
70
+ if (lower.includes('no bucket selected') || lower.includes('not found in bucketconnection')) {
71
+ return { status: 400, code: 'INVALID_BUCKET', message };
72
+ }
73
+ if (lower.includes('access denied')) {
74
+ return { status: 403, code: 'ACCESS_DENIED', message };
75
+ }
76
+ if (lower.includes('not found')) {
77
+ return { status: 404, code: 'NOT_FOUND', message };
78
+ }
79
+ if (lower.includes('invalid') || lower.includes('required')) {
80
+ return { status: 400, code: 'VALIDATION_ERROR', message };
81
+ }
82
+ return { status: 500, code: 'INTERNAL_ERROR', message };
83
+ }
84
+
85
+ function sendViewError(res, error, fallbackMessage = 'Request failed') {
86
+ const mapped = classifyViewError(error, fallbackMessage);
87
+ return res.status(mapped.status).json({ code: mapped.code, message: mapped.message });
88
+ }
89
+
90
+ // Central bucket guard for view routes except public view (forced default bucket).
91
+ router.use((req, res, next) => {
92
+ if (req.path.startsWith('/p_view/')) return next();
93
+ if (req.bucketResolveError) return sendViewError(res, req.bucketResolveError, 'Invalid bucket selection');
94
+ if (!req.activeBucket) {
95
+ return sendViewError(res, new Error('No bucket selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.'));
96
+ }
97
+ next();
98
+ });
99
+
66
100
  // New: lightweight player page for robust playback in browsers
67
101
  router.get('/player/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
68
102
  try {
103
+ const bucketName = req.activeBucket;
69
104
  const key = req.params.key;
70
105
  let keyToUse;
71
106
  try {
@@ -76,6 +111,7 @@ router.get('/player/:key(*)', validateSessionAndRole('SuperAdmin'), async (req,
76
111
  const encoded = encodeURIComponent(keyToUse);
77
112
  const fileName = keyToUse.split('/').pop();
78
113
 
114
+ const bucketQuery = `?bucket=${encodeURIComponent(bucketName)}`;
79
115
  const html = `<!doctype html>
80
116
  <html lang="en">
81
117
  <head>
@@ -93,7 +129,7 @@ router.get('/player/:key(*)', validateSessionAndRole('SuperAdmin'), async (req,
93
129
  <body>
94
130
  <div class="file-name">${fileName}</div>
95
131
  <div class="player-container">
96
- <video controls preload="metadata" src="/mbkbucket/view/${encoded}">
132
+ <video controls preload="metadata" src="/mbkbucket/view/${encoded}${bucketQuery}">
97
133
  Your browser does not support the video tag.
98
134
  </video>
99
135
  </div>
@@ -160,7 +196,28 @@ const MIME_TYPES = {
160
196
  // Options:
161
197
  // noindex {boolean} – adds X-Robots-Tag: noindex, nofollow (default false)
162
198
  // ---------------------------------------------------------------------------
163
- async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
199
+ function isSensitiveTextType(contentType = '') {
200
+ const type = String(contentType || '').toLowerCase();
201
+ return type.startsWith('text/')
202
+ || type.includes('json')
203
+ || type.includes('xml')
204
+ || type.includes('javascript')
205
+ || type.includes('typescript');
206
+ }
207
+
208
+ function buildCacheControl(contentType, { isStaticAsset = false, supportsRanges = false, publicCache = false } = {}) {
209
+ if (publicCache) {
210
+ if (isStaticAsset) return 'public, max-age=3600, immutable';
211
+ if (supportsRanges) return 'public, max-age=86400, must-revalidate';
212
+ return 'public, max-age=300, must-revalidate';
213
+ }
214
+
215
+ if (isSensitiveTextType(contentType)) return 'private, no-store';
216
+ if (supportsRanges) return 'private, max-age=1800, must-revalidate';
217
+ return 'private, max-age=300, must-revalidate';
218
+ }
219
+
220
+ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName, publicCache = false } = {}) {
164
221
  const fileName = keyToUse.split('/').pop();
165
222
  const fileExtension = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : '';
166
223
 
@@ -193,7 +250,7 @@ async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
193
250
  console.log(`[mbkbucket] Processing range request: ${rangeHeader} for ${fileName}`);
194
251
  let meta;
195
252
  try {
196
- meta = await getFileMetadata(keyToUse);
253
+ meta = await getFileMetadata(keyToUse, bucketName);
197
254
  } catch (err) {
198
255
  console.error('[mbkbucket] serveFileInline metadata error:', err);
199
256
  return res.status(500).json({ message: 'Failed to fetch file metadata', error: err.message });
@@ -249,7 +306,7 @@ async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
249
306
  const chunksize = end - start + 1;
250
307
 
251
308
  try {
252
- const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}` });
309
+ const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, bucketName });
253
310
 
254
311
  if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
255
312
  console.warn(`Range size mismatch for ${keyToUse}: expected=${chunksize} got=${rangeResult.ContentLength}`);
@@ -261,7 +318,7 @@ async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
261
318
  res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
262
319
  res.setHeader('Content-Length', chunksize);
263
320
  res.setHeader('Content-Type', contentType);
264
- res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate');
321
+ res.setHeader('Cache-Control', buildCacheControl(contentType, { supportsRanges: true, publicCache }));
265
322
  res.setHeader('ETag', etag);
266
323
  res.setHeader('Accept-Ranges', 'bytes');
267
324
  if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
@@ -308,7 +365,8 @@ async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
308
365
  // ---------------------------------------------------------------------------
309
366
  const downloadOptions = {
310
367
  ...(ifNoneMatch && { ifNoneMatch }),
311
- ...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) })
368
+ ...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) }),
369
+ bucketName
312
370
  };
313
371
 
314
372
  if (isPdf) {
@@ -346,14 +404,7 @@ async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
346
404
  }
347
405
  if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
348
406
 
349
- if (isStaticAsset) {
350
- res.setHeader('Cache-Control', 'public, max-age=3600, immutable');
351
- } else if (supportsRanges) {
352
- // For video, audio, and PDF - enable caching with revalidation
353
- res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate');
354
- } else {
355
- res.setHeader('Cache-Control', 'public, max-age=300');
356
- }
407
+ res.setHeader('Cache-Control', buildCacheControl(contentType, { isStaticAsset, supportsRanges, publicCache }));
357
408
 
358
409
  res.status(200);
359
410
 
@@ -393,6 +444,7 @@ async function serveFileInline(req, res, keyToUse, { noindex = true } = {}) {
393
444
 
394
445
  router.get('/view/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
395
446
  try {
447
+ const bucketName = req.activeBucket;
396
448
  let keyToUse;
397
449
  try {
398
450
  keyToUse = ensureKeyHasAppPrefix(req.params.key);
@@ -400,7 +452,7 @@ router.get('/view/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, re
400
452
  return res.status(400).json({ message: e.message });
401
453
  }
402
454
 
403
- await serveFileInline(req, res, keyToUse, { noindex: true });
455
+ await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: false });
404
456
 
405
457
  } catch (error) {
406
458
  console.error("Error viewing file:", error);
@@ -417,6 +469,7 @@ router.get('/view/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, re
417
469
  if (mbkbucketVar?.publiView_enabled) {
418
470
  router.get('/p_view/:key(*)', pviewRateLimit, pviewSecurity, async (req, res) => {
419
471
  try {
472
+ const bucketName = resolveBucketName();
420
473
  const key = req.params.key;
421
474
 
422
475
  // No directory indexing — reject folder-like or empty keys
@@ -438,7 +491,7 @@ if (mbkbucketVar?.publiView_enabled) {
438
491
  if (!p_view_inline) {
439
492
  // Signed URL mode — redirect to a pre-signed S3 URL (1 hour expiry)
440
493
  try {
441
- const signed = await generateSignedUrl(keyToUse, 'getObject', 3600);
494
+ const signed = await generateSignedUrl(keyToUse, 'getObject', 3600, bucketName);
442
495
  return res.redirect(302, signed.url);
443
496
  } catch (err) {
444
497
  console.error('[mbkbucket] p_view signed URL error:', err);
@@ -447,7 +500,7 @@ if (mbkbucketVar?.publiView_enabled) {
447
500
  }
448
501
 
449
502
  // Direct serve mode — stream file inline (with noindex header)
450
- await serveFileInline(req, res, keyToUse, { noindex: true });
503
+ await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: true });
451
504
  } catch (error) {
452
505
  console.error('[mbkbucket] p_view error:', error);
453
506
  res.status(500).json({ message: 'Internal server error', error: error.message });
package/lib/s3.js CHANGED
@@ -19,6 +19,10 @@ function parseJsonEnv(envVar, fallback = {}) {
19
19
  // Parse BucketConnection JSON that contains all bucket configurations
20
20
  const allBucketConfigs = parseJsonEnv(process.env.BucketConnection, {});
21
21
 
22
+ export function getAvailableBucketNames() {
23
+ return Object.keys(allBucketConfigs || {});
24
+ }
25
+
22
26
  // Helper to get default bucket name
23
27
  function getDefaultBucketName() {
24
28
  // First priority: use mbkautheVar.bucket if configured
@@ -36,6 +40,26 @@ function getDefaultBucketName() {
36
40
  return null;
37
41
  }
38
42
 
43
+ export function resolveBucketName(bucketName) {
44
+ const candidate = (bucketName === undefined || bucketName === null)
45
+ ? getDefaultBucketName()
46
+ : String(bucketName).trim();
47
+
48
+ if (!candidate) {
49
+ throw new Error('No bucket selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.');
50
+ }
51
+
52
+ if (!allBucketConfigs || Object.keys(allBucketConfigs).length === 0) {
53
+ throw new Error('BucketConnection environment variable is not set or empty. Please configure it with your bucket connections.');
54
+ }
55
+
56
+ if (!allBucketConfigs[candidate]) {
57
+ throw new Error(`Bucket '${candidate}' not found in BucketConnection. Available buckets: ${Object.keys(allBucketConfigs).join(', ')}`);
58
+ }
59
+
60
+ return candidate;
61
+ }
62
+
39
63
  // (bucketClient singleton exported below, after cache setup)
40
64
 
41
65
  // Helper: get configured APP_NAME; prefer mbkautheVar, then env var
@@ -44,9 +68,12 @@ export function getAppName() {
44
68
  return (typeof app === 'string' && app.trim()) ? app.trim() : '';
45
69
  }
46
70
 
71
+ function isRootModeApp(appName) {
72
+ return String(appName || '').trim().toLowerCase() === 'portal';
73
+ }
74
+
47
75
  export function ensureKeyHasAppPrefix(key = '') {
48
76
  const app = getAppName();
49
- if (!app) throw new Error('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
50
77
  if (!key) throw new Error('Key is required');
51
78
 
52
79
  // Security: Detect path traversal attempts
@@ -60,6 +87,14 @@ export function ensureKeyHasAppPrefix(key = '') {
60
87
  }
61
88
 
62
89
  let cleaned = String(key).replace(/^\/+/, '');
90
+
91
+ // Special case: APP_NAME=portal means operate from bucket root.
92
+ if (isRootModeApp(app)) {
93
+ return cleaned;
94
+ }
95
+
96
+ if (!app) throw new Error('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
97
+
63
98
  // Collapse repeated app prefixes (app/app/... -> app/...)
64
99
  const appPrefixRegex = new RegExp(`^(?:${app}\/)+`);
65
100
  if (!cleaned.startsWith(`${app}/`)) {
@@ -73,7 +108,6 @@ export function ensureKeyHasAppPrefix(key = '') {
73
108
 
74
109
  export function ensurePrefix(prefix = '') {
75
110
  const app = getAppName();
76
- if (!app) throw new Error('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
77
111
 
78
112
  // Security: Detect path traversal attempts
79
113
  if (prefix && (prefix.includes('../') || prefix.includes('..\\') || /\.\.[\\/]/.test(prefix))) {
@@ -87,6 +121,13 @@ export function ensurePrefix(prefix = '') {
87
121
 
88
122
  // Only strip LEADING slashes. Preserve trailing slashes for directory listing.
89
123
  let p = String(prefix || '').replace(/^\/+/, '');
124
+
125
+ // Special case: APP_NAME=portal means operate from bucket root.
126
+ if (isRootModeApp(app)) {
127
+ return p;
128
+ }
129
+
130
+ if (!app) throw new Error('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
90
131
 
91
132
  if (!p) return `${app}`;
92
133
 
@@ -110,13 +151,7 @@ const _clientCache = new Map();
110
151
  const _configCache = new Map();
111
152
 
112
153
  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
- }
154
+ bucketName = resolveBucketName(bucketName);
120
155
 
121
156
  const cacheKey = bucketName;
122
157
  if (_configCache.has(cacheKey)) return _configCache.get(cacheKey);
@@ -144,10 +179,7 @@ export function getBucketConfig(bucketName) {
144
179
 
145
180
  // Function to get S3 client for specific bucket — returns cached singleton
146
181
  export function getBucketClient(bucketName) {
147
- // Resolve default bucket name if not provided
148
- if (!bucketName) {
149
- bucketName = getDefaultBucketName();
150
- }
182
+ bucketName = resolveBucketName(bucketName);
151
183
 
152
184
  if (_clientCache.has(bucketName)) return _clientCache.get(bucketName);
153
185
 
@@ -619,10 +651,10 @@ export { checkHealth };
619
651
  /**
620
652
  * Initiate a multipart upload. Returns the S3 UploadId.
621
653
  */
622
- export async function createMultipartUpload(key, contentType = 'application/octet-stream', metadata = {}) {
654
+ export async function createMultipartUpload(key, contentType = 'application/octet-stream', metadata = {}, bucketName) {
623
655
  const keyToUse = ensureKeyHasAppPrefix(key);
624
- const cfg = getBucketConfig();
625
- const client = getBucketClient();
656
+ const cfg = getBucketConfig(bucketName);
657
+ const client = getBucketClient(bucketName);
626
658
  const command = new CreateMultipartUploadCommand({
627
659
  Bucket: cfg.BUCKET_NAME,
628
660
  Key: keyToUse,
@@ -641,10 +673,10 @@ export async function createMultipartUpload(key, contentType = 'application/octe
641
673
  * Upload a single part. partNumber is 1-based.
642
674
  * Returns the ETag needed for CompleteMultipartUpload.
643
675
  */
644
- export async function uploadPart(key, uploadId, partNumber, buffer) {
676
+ export async function uploadPart(key, uploadId, partNumber, buffer, bucketName) {
645
677
  const keyToUse = ensureKeyHasAppPrefix(key);
646
- const cfg = getBucketConfig();
647
- const client = getBucketClient();
678
+ const cfg = getBucketConfig(bucketName);
679
+ const client = getBucketClient(bucketName);
648
680
  const command = new UploadPartCommand({
649
681
  Bucket: cfg.BUCKET_NAME,
650
682
  Key: keyToUse,
@@ -661,10 +693,10 @@ export async function uploadPart(key, uploadId, partNumber, buffer) {
661
693
  * Complete a multipart upload.
662
694
  * parts must be an array of { partNumber, ETag } sorted ascending by partNumber.
663
695
  */
664
- export async function completeMultipartUpload(key, uploadId, parts) {
696
+ export async function completeMultipartUpload(key, uploadId, parts, bucketName) {
665
697
  const keyToUse = ensureKeyHasAppPrefix(key);
666
- const cfg = getBucketConfig();
667
- const client = getBucketClient();
698
+ const cfg = getBucketConfig(bucketName);
699
+ const client = getBucketClient(bucketName);
668
700
  const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber);
669
701
  const command = new CompleteMultipartUploadCommand({
670
702
  Bucket: cfg.BUCKET_NAME,
@@ -681,10 +713,10 @@ export async function completeMultipartUpload(key, uploadId, parts) {
681
713
  /**
682
714
  * Abort an in-progress multipart upload, freeing all stored parts.
683
715
  */
684
- export async function abortMultipartUpload(key, uploadId) {
716
+ export async function abortMultipartUpload(key, uploadId, bucketName) {
685
717
  const keyToUse = ensureKeyHasAppPrefix(key);
686
- const cfg = getBucketConfig();
687
- const client = getBucketClient();
718
+ const cfg = getBucketConfig(bucketName);
719
+ const client = getBucketClient(bucketName);
688
720
  const command = new AbortMultipartUploadCommand({
689
721
  Bucket: cfg.BUCKET_NAME,
690
722
  Key: keyToUse,
@@ -695,26 +727,26 @@ export async function abortMultipartUpload(key, uploadId) {
695
727
  }
696
728
 
697
729
  // Generate signed URL for temporary access
698
- export async function generateSignedUrl(key, operation = 'getObject', expiresIn = 3600) {
730
+ export async function generateSignedUrl(key, operation = 'getObject', expiresIn = 3600, bucketName) {
699
731
  try {
700
732
  const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
701
733
  const keyToUse = ensureKeyHasAppPrefix(key);
702
734
 
703
735
  let command;
704
- const cfg = getBucketConfig();
705
- const bucketName = cfg.BUCKET_NAME;
736
+ const cfg = getBucketConfig(bucketName);
737
+ const resolvedBucketName = cfg.BUCKET_NAME;
706
738
  switch (operation) {
707
739
  case 'getObject':
708
- command = new GetObjectCommand({ Bucket: bucketName, Key: keyToUse });
740
+ command = new GetObjectCommand({ Bucket: resolvedBucketName, Key: keyToUse });
709
741
  break;
710
742
  case 'putObject':
711
- command = new PutObjectCommand({ Bucket: bucketName, Key: keyToUse });
743
+ command = new PutObjectCommand({ Bucket: resolvedBucketName, Key: keyToUse });
712
744
  break;
713
745
  default:
714
746
  throw new Error(`Unsupported operation: ${operation}`);
715
747
  }
716
748
 
717
- const signedUrl = await getSignedUrl(bucketClient, command, { expiresIn });
749
+ const signedUrl = await getSignedUrl(getBucketClient(bucketName), command, { expiresIn });
718
750
 
719
751
  return {
720
752
  url: signedUrl,
@@ -742,7 +774,7 @@ export async function generateSignedUrl(key, operation = 'getObject', expiresIn
742
774
  */
743
775
  export async function listIncompleteMultipartUploads(prefix = '', bucketName) {
744
776
  try {
745
- const effectivePrefix = prefix ? ensurePrefix(prefix) : getAppName();
777
+ const effectivePrefix = ensurePrefix(prefix || '');
746
778
  const cfg = getBucketConfig(bucketName);
747
779
  const client = getBucketClient(bucketName);
748
780
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mbkbucket",
3
- "version": "1.5.0",
3
+ "version": "2.0.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",
@@ -10,7 +10,7 @@
10
10
  "types": "index.d.ts",
11
11
  "scripts": {
12
12
  "dev": "cross-env mbkbucket_test=dev nodemon index.js",
13
- "test": "echo \\\"Error: no test specified\\\" && exit 1"
13
+ "test": "node --test"
14
14
  },
15
15
  "dependencies": {
16
16
  "@aws-sdk/client-s3": "^3.968.0",