mbkbucket 4.0.1 → 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.
@@ -3,7 +3,7 @@ import { packageJson, getLatestVersion, appVersion, mbkbucketVar } from "../conf
3
3
  import { getAvailableBucketNames, getStorageProvider } from "../services/storage.service.js";
4
4
  import { storageManager } from "../core/storage/storage-manager.js";
5
5
 
6
- async function getInfoData() {
6
+ async function getInfoData(requestedBucket) {
7
7
  let latestVersion = 'unknown';
8
8
  try {
9
9
  latestVersion = (await getLatestVersion()) || 'unknown';
@@ -12,16 +12,21 @@ async function getInfoData() {
12
12
  }
13
13
 
14
14
  const buckets = getAvailableBucketNames();
15
- const defaultBucket = storageManager.getDefaultConnectionName() || (buckets[0] ?? '');
15
+ let defaultBucket = requestedBucket;
16
+ if (!defaultBucket || !buckets.includes(defaultBucket)) {
17
+ defaultBucket = storageManager.getDefaultConnectionName() || (buckets[0] ?? '');
18
+ }
19
+
16
20
  let providerType = 'S3';
21
+ let capabilities = {};
17
22
  if (defaultBucket) {
18
23
  try {
19
24
  const provider = getStorageProvider(defaultBucket);
20
25
  providerType = (provider?.type || 's3').toUpperCase();
26
+ capabilities = provider?.capabilities?.toJSON ? provider.capabilities.toJSON() : (provider?.capabilities || {});
21
27
  } catch {}
22
28
  }
23
29
 
24
- // Safe subset only - strictly prevent leaking passwords, DB connection strings, OAuth secrets, or storage credentials
25
30
  const safeMbkautheVar = {
26
31
  APP_NAME: mbkautheVar?.APP_NAME || mbkautheVar?.app_name || "mbkbucket",
27
32
  };
@@ -41,24 +46,19 @@ async function getInfoData() {
41
46
  mbkbucketVar: safeMbkbucketVar,
42
47
  defaultBucket,
43
48
  configuredBucketsCount: buckets.length,
44
- providerType
49
+ providerType,
50
+ capabilities
45
51
  };
46
52
  }
47
53
 
48
- /**
49
- * Render the mbkbucket info page (Handlebars).
50
- */
51
54
  export async function infoPage(req, res) {
52
55
  renderPage(req, res, "mbkbucket_info.hbs", false, {
53
56
  pageTitle: "MBKBucket Information",
54
57
  ogUrl: "/mbkbucket/info",
55
- ...(await getInfoData())
58
+ ...(await getInfoData(req.query?.bucket))
56
59
  });
57
60
  }
58
61
 
59
- /**
60
- * Return bucket info as JSON.
61
- */
62
- export async function infoJson(_req, res) {
63
- res.json(await getInfoData());
62
+ export async function infoJson(req, res) {
63
+ res.json(await getInfoData(req.query?.bucket));
64
64
  }
@@ -1,4 +1,4 @@
1
- import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, generateSignedUrl, resolveStorageName, getStorageProvider } from "../services/storage.service.js";
1
+ import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, resolveStorageName, getStorageProvider } from "../services/storage.service.js";
2
2
  import { mbkbucketVar } from "../config/index.js";
3
3
  import { escapeHtml, getBaseName, getFileExt, getMimeType, normalizeKeyParam, destroyStream, VIEWABLE_TYPES, VIEWABLE_TYPES_SORTED, STATIC_ASSET_TYPES, VIDEO_TYPES, AUDIO_TYPES, buildCacheControl, parseRangeHeader, renderPlayerPage } from "../utils/helpers.js";
4
4
  import { createLogger } from "#logger";
@@ -6,6 +6,7 @@ import { createLogger } from "#logger";
6
6
  const debugView = createLogger('view');
7
7
 
8
8
  function attachStreamCleanup(req, res, stream, timeoutMs = 120000) {
9
+ if (!stream || typeof stream.on !== 'function') return;
9
10
  const cleanup = () => destroyStream(stream);
10
11
 
11
12
  stream.on('error', (err) => {
@@ -25,6 +26,7 @@ function attachStreamCleanup(req, res, stream, timeoutMs = 120000) {
25
26
  res.on('close', cleanup);
26
27
  }
27
28
 
29
+
28
30
  async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName, publicCache = false } = {}) {
29
31
  const fileName = getBaseName(keyToUse);
30
32
  const fileExtension = getFileExt(fileName);
@@ -48,6 +50,8 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
48
50
  const isPdf = fileExtension === 'pdf';
49
51
  const supportsRanges = isVideo || isAudio || isPdf;
50
52
  const contentType = getMimeType(fileName);
53
+ const safeAsciiName = fileName.replace(/["\\]/g, '').replace(/[^\x20-\x7E]/g, '_');
54
+ const encodedName = encodeURIComponent(fileName);
51
55
 
52
56
  // Fast path for range requests (video / audio / PDF streaming)
53
57
  if (supportsRanges && rangeHeader) {
@@ -86,21 +90,27 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
86
90
  res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
87
91
  res.setHeader('Content-Length', chunksize);
88
92
  res.setHeader('Content-Type', contentType);
93
+ res.setHeader('Content-Disposition', `inline; filename="${safeAsciiName}"; filename*=UTF-8''${encodedName}`);
89
94
  res.setHeader('Cache-Control', buildCacheControl(contentType, { supportsRanges: true, publicCache }));
90
95
  res.setHeader('ETag', etag);
91
96
  res.setHeader('Accept-Ranges', 'bytes');
97
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
92
98
  if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
93
99
  res.setHeader('X-Content-Type-Options', 'nosniff');
94
- if (meta.LastModified) res.setHeader('Last-Modified', new Date(meta.LastModified).toUTCString());
95
-
96
- attachStreamCleanup(req, res, rangeResult.Body);
97
- return rangeResult.Body.pipe(res);
100
+ const stream = rangeResult.Body || rangeResult.stream;
101
+ attachStreamCleanup(req, res, stream);
102
+ return stream.pipe(res);
98
103
  } catch (rangeError) {
99
- console.error('Range request failed:', rangeError);
104
+ console.error('[mbkbucket] Range request failed for %s:', keyToUse, rangeError);
105
+ if (!res.headersSent) {
106
+ return res.status(500).json({ message: 'Range stream failed', error: rangeError.message });
107
+ }
108
+ return;
100
109
  }
101
110
  }
102
111
  }
103
112
 
113
+
104
114
  // Standard path: full-file download
105
115
  const downloadOptions = {
106
116
  ...(ifNoneMatch && { ifNoneMatch }),
@@ -130,13 +140,14 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
130
140
  if (ifNoneMatch === etag) return res.status(304).end();
131
141
 
132
142
  res.setHeader('Content-Type', contentType);
133
- res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
143
+ res.setHeader('Content-Disposition', `inline; filename="${safeAsciiName}"; filename*=UTF-8''${encodedName}`);
134
144
  res.setHeader('ETag', etag);
135
145
  res.setHeader('X-Content-Type-Options', 'nosniff');
136
146
  res.setHeader('X-Frame-Options', 'SAMEORIGIN');
137
147
  if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
138
148
  if (result.ContentLength) res.setHeader('Content-Length', result.ContentLength);
139
149
  if (result.LastModified) res.setHeader('Last-Modified', new Date(result.LastModified).toUTCString());
150
+
140
151
  if (fileExtension === 'html' || fileExtension === 'htm') {
141
152
  res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
142
153
  }
@@ -144,11 +155,13 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
144
155
  res.setHeader('Cache-Control', buildCacheControl(contentType, { isStaticAsset, supportsRanges, publicCache }));
145
156
  res.status(200);
146
157
 
158
+ const stream = result.Body || result.stream;
147
159
  const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
148
- attachStreamCleanup(req, res, result.Body, timeoutDuration);
149
- result.Body.pipe(res);
160
+ attachStreamCleanup(req, res, stream, timeoutDuration);
161
+ stream.pipe(res);
150
162
  }
151
163
 
164
+
152
165
  export async function viewFile(req, res) {
153
166
  try {
154
167
  const key = normalizeKeyParam(req.params.key);
@@ -213,18 +226,6 @@ export async function publicView(req, res) {
213
226
  return res.status(400).json({ message: e.message });
214
227
  }
215
228
 
216
- if (!mbkbucketVar?.p_view_inline) {
217
- try {
218
- const provider = getStorageProvider(bucketName);
219
- if (provider.capabilities.presignedUrls) {
220
- const signed = await generateSignedUrl(keyToUse, 'getObject', 3600, bucketName);
221
- return res.redirect(302, signed.url);
222
- }
223
- } catch (err) {
224
- debugView('Signed URL redirection failed, falling back to inline serve: %s', err.message);
225
- }
226
- }
227
-
228
229
  await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: true });
229
230
  } catch (error) {
230
231
  console.error('[mbkbucket] p_view error:', error);
@@ -0,0 +1,134 @@
1
+ /**
2
+ * MBKBucket - Metrics & Observability Registry
3
+ * Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
4
+ * Licensed under the MIT License.
5
+ */
6
+
7
+ export class MetricsRegistry {
8
+ constructor() {
9
+ this.reset();
10
+ }
11
+
12
+ reset() {
13
+ this.operations = new Map(); // key: "op:provider:status" -> count
14
+ this.durations = new Map(); // key: "op:provider" -> { totalMs, count, minMs, maxMs }
15
+ this.bytesTransferred = new Map(); // key: "direction:provider" -> bytes
16
+ this.errors = new Map(); // key: "op:provider:errorType" -> count
17
+ this.activeMultipart = 0;
18
+ this.startedAt = new Date().toISOString();
19
+ }
20
+
21
+ recordOperation(operation, { provider = 'unknown', status = 'success', durationMs = 0, bytes = 0 } = {}) {
22
+ const opKey = `${operation}:${provider}:${status}`;
23
+ this.operations.set(opKey, (this.operations.get(opKey) || 0) + 1);
24
+
25
+ if (durationMs >= 0) {
26
+ const durKey = `${operation}:${provider}`;
27
+ const existing = this.durations.get(durKey) || { totalMs: 0, count: 0, minMs: Infinity, maxMs: 0 };
28
+ existing.totalMs += durationMs;
29
+ existing.count += 1;
30
+ existing.minMs = Math.min(existing.minMs, durationMs);
31
+ existing.maxMs = Math.max(existing.maxMs, durationMs);
32
+ this.durations.set(durKey, existing);
33
+ }
34
+
35
+ if (bytes > 0) {
36
+ const direction = operation.startsWith('download') || operation.startsWith('view') ? 'outbound' : 'inbound';
37
+ const byteKey = `${direction}:${provider}`;
38
+ this.bytesTransferred.set(byteKey, (this.bytesTransferred.get(byteKey) || 0) + bytes);
39
+ }
40
+ }
41
+
42
+ recordError(operation, provider = 'unknown', errorType = 'Error') {
43
+ const errKey = `${operation}:${provider}:${errorType}`;
44
+ this.errors.set(errKey, (this.errors.get(errKey) || 0) + 1);
45
+ }
46
+
47
+ incrementActiveMultipart(delta = 1) {
48
+ this.activeMultipart = Math.max(0, this.activeMultipart + delta);
49
+ }
50
+
51
+ getStats() {
52
+ const ops = {};
53
+ for (const [key, count] of this.operations.entries()) {
54
+ ops[key] = count;
55
+ }
56
+
57
+ const durations = {};
58
+ for (const [key, d] of this.durations.entries()) {
59
+ durations[key] = {
60
+ count: d.count,
61
+ avgMs: d.count ? Math.round((d.totalMs / d.count) * 100) / 100 : 0,
62
+ minMs: d.minMs === Infinity ? 0 : d.minMs,
63
+ maxMs: d.maxMs
64
+ };
65
+ }
66
+
67
+ const bytes = {};
68
+ for (const [key, val] of this.bytesTransferred.entries()) {
69
+ bytes[key] = val;
70
+ }
71
+
72
+ const errs = {};
73
+ for (const [key, count] of this.errors.entries()) {
74
+ errs[key] = count;
75
+ }
76
+
77
+ return {
78
+ startedAt: this.startedAt,
79
+ activeMultipartUploads: this.activeMultipart,
80
+ operations: ops,
81
+ durations,
82
+ bytesTransferred: bytes,
83
+ errors: errs,
84
+ timestamp: new Date().toISOString()
85
+ };
86
+ }
87
+
88
+ toPrometheusText() {
89
+ const lines = [
90
+ '# HELP mbkbucket_operations_total Total number of storage operations executed',
91
+ '# TYPE mbkbucket_operations_total counter'
92
+ ];
93
+
94
+ for (const [key, count] of this.operations.entries()) {
95
+ const [operation, provider, status] = key.split(':');
96
+ lines.push(`mbkbucket_operations_total{operation="${operation}",provider="${provider}",status="${status}"} ${count}`);
97
+ }
98
+
99
+ lines.push('');
100
+ lines.push('# HELP mbkbucket_operation_duration_seconds Total duration of storage operations');
101
+ lines.push('# TYPE mbkbucket_operation_duration_seconds summary');
102
+ for (const [key, d] of this.durations.entries()) {
103
+ const [operation, provider] = key.split(':');
104
+ lines.push(`mbkbucket_operation_duration_seconds_count{operation="${operation}",provider="${provider}"} ${d.count}`);
105
+ lines.push(`mbkbucket_operation_duration_seconds_sum{operation="${operation}",provider="${provider}"} ${(d.totalMs / 1000).toFixed(4)}`);
106
+ }
107
+
108
+ lines.push('');
109
+ lines.push('# HELP mbkbucket_transferred_bytes_total Total bytes transferred into or out of storage');
110
+ lines.push('# TYPE mbkbucket_transferred_bytes_total counter');
111
+ for (const [key, bytes] of this.bytesTransferred.entries()) {
112
+ const [direction, provider] = key.split(':');
113
+ lines.push(`mbkbucket_transferred_bytes_total{direction="${direction}",provider="${provider}"} ${bytes}`);
114
+ }
115
+
116
+ lines.push('');
117
+ lines.push('# HELP mbkbucket_errors_total Total storage errors encountered');
118
+ lines.push('# TYPE mbkbucket_errors_total counter');
119
+ for (const [key, count] of this.errors.entries()) {
120
+ const [operation, provider, errorType] = key.split(':');
121
+ lines.push(`mbkbucket_errors_total{operation="${operation}",provider="${provider}",error_type="${errorType}"} ${count}`);
122
+ }
123
+
124
+ lines.push('');
125
+ lines.push('# HELP mbkbucket_active_multipart_uploads Current number of active multipart uploads');
126
+ lines.push('# TYPE mbkbucket_active_multipart_uploads gauge');
127
+ lines.push(`mbkbucket_active_multipart_uploads ${this.activeMultipart}`);
128
+ lines.push('');
129
+
130
+ return lines.join('\n');
131
+ }
132
+ }
133
+
134
+ export const metricsRegistry = new MetricsRegistry();
@@ -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
+ }
@@ -1,62 +1,57 @@
1
- /**
2
- * MBKBucket - Typed Storage Error Hierarchy
3
- * Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
4
- * Licensed under the MIT License.
5
- */
6
-
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
- constructor(message, status = 500, code = 'INTERNAL_STORAGE_ERROR') {
14
- super(message);
15
- this.name = this.constructor.name;
16
- this.status = status;
17
- this.code = code;
18
- Error.captureStackTrace?.(this, this.constructor);
19
- }
20
- }
21
-
22
- export class StorageNotFoundError extends StorageError {
23
- constructor(message = 'File or folder not found') {
24
- super(message, 404, 'NOT_FOUND');
25
- }
26
- }
27
-
28
- export class StorageAccessDeniedError extends StorageError {
29
- constructor(message = 'Access denied') {
30
- super(message, 403, 'ACCESS_DENIED');
31
- }
32
- }
33
-
34
- export class StorageConflictError extends StorageError {
35
- constructor(message = 'Resource already exists') {
36
- super(message, 409, 'CONFLICT');
37
- }
38
- }
39
-
40
- export class StorageValidationError extends StorageError {
41
- constructor(message = 'Validation failed') {
42
- super(message, 400, 'VALIDATION_ERROR');
43
- }
44
- }
45
-
46
- export class StorageConfigError extends StorageError {
47
- constructor(message = 'Storage configuration error') {
48
- super(message, 503, 'STORAGE_CONFIG_ERROR');
49
- }
50
- }
51
-
52
- export class StorageQuotaExceededError extends StorageError {
53
- constructor(message = 'Storage quota exceeded') {
54
- super(message, 507, 'QUOTA_EXCEEDED');
55
- }
56
- }
57
-
58
- export class StorageUnsupportedOperationError extends StorageError {
59
- constructor(message = 'Operation not supported by this storage provider') {
60
- super(message, 501, 'NOT_IMPLEMENTED');
61
- }
62
- }
1
+ /**
2
+ * MBKBucket - Typed Storage Error Hierarchy
3
+ * Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
4
+ * Licensed under the MIT License.
5
+ */
6
+
7
+ export class StorageError extends Error {
8
+ constructor(message, status = 500, code = 'INTERNAL_STORAGE_ERROR') {
9
+ super(message);
10
+ this.name = this.constructor.name;
11
+ this.status = status;
12
+ this.code = code;
13
+ Error.captureStackTrace?.(this, this.constructor);
14
+ }
15
+ }
16
+
17
+ export class StorageNotFoundError extends StorageError {
18
+ constructor(message = 'File or folder not found') {
19
+ super(message, 404, 'NOT_FOUND');
20
+ }
21
+ }
22
+
23
+ export class StorageAccessDeniedError extends StorageError {
24
+ constructor(message = 'Access denied') {
25
+ super(message, 403, 'ACCESS_DENIED');
26
+ }
27
+ }
28
+
29
+ export class StorageConflictError extends StorageError {
30
+ constructor(message = 'Resource already exists') {
31
+ super(message, 409, 'CONFLICT');
32
+ }
33
+ }
34
+
35
+ export class StorageValidationError extends StorageError {
36
+ constructor(message = 'Validation failed') {
37
+ super(message, 400, 'VALIDATION_ERROR');
38
+ }
39
+ }
40
+
41
+ export class StorageConfigError extends StorageError {
42
+ constructor(message = 'Storage configuration error') {
43
+ super(message, 503, 'STORAGE_CONFIG_ERROR');
44
+ }
45
+ }
46
+
47
+ export class StorageQuotaExceededError extends StorageError {
48
+ constructor(message = 'Storage quota exceeded') {
49
+ super(message, 507, 'QUOTA_EXCEEDED');
50
+ }
51
+ }
52
+
53
+ export class StorageUnsupportedOperationError extends StorageError {
54
+ constructor(message = 'Operation not supported by this storage provider') {
55
+ super(message, 501, 'NOT_IMPLEMENTED');
56
+ }
57
+ }