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
@@ -1,233 +1,234 @@
1
- import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, generateSignedUrl, resolveStorageName, getStorageProvider } from "../services/storage.service.js";
2
- import { mbkbucketVar } from "../config/index.js";
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
- import { createLogger } from "#logger";
5
-
6
- const debugView = createLogger('view');
7
-
8
- function attachStreamCleanup(req, res, stream, timeoutMs = 120000) {
9
- const cleanup = () => destroyStream(stream);
10
-
11
- stream.on('error', (err) => {
12
- console.error('Stream error:', err);
13
- cleanup();
14
- if (!res.headersSent) res.status(500).end('Stream error');
15
- });
16
-
17
- req.on('close', cleanup);
18
- req.on('aborted', cleanup);
19
- req.setTimeout(timeoutMs, () => {
20
- console.error('Request timeout for stream');
21
- cleanup();
22
- if (!res.headersSent) res.status(408).end('Request timeout');
23
- });
24
- res.on('finish', cleanup);
25
- res.on('close', cleanup);
26
- }
27
-
28
- async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName, publicCache = false } = {}) {
29
- const fileName = getBaseName(keyToUse);
30
- const fileExtension = getFileExt(fileName);
31
-
32
- debugView('Request for %s (type: %s, range: %s)', fileName, fileExtension, req.headers.range || 'none');
33
-
34
- if (!VIEWABLE_TYPES.has(fileExtension)) {
35
- return res.status(415).json({
36
- message: 'File type not supported for viewing',
37
- supportedTypes: VIEWABLE_TYPES_SORTED
38
- });
39
- }
40
-
41
- const ifNoneMatch = req.headers['if-none-match'];
42
- const ifModifiedSince = req.headers['if-modified-since'];
43
- const rangeHeader = req.headers.range;
44
-
45
- const isStaticAsset = STATIC_ASSET_TYPES.has(fileExtension);
46
- const isVideo = VIDEO_TYPES.has(fileExtension);
47
- const isAudio = AUDIO_TYPES.has(fileExtension);
48
- const isPdf = fileExtension === 'pdf';
49
- const supportsRanges = isVideo || isAudio || isPdf;
50
- const contentType = getMimeType(fileName);
51
-
52
- // Fast path for range requests (video / audio / PDF streaming)
53
- if (supportsRanges && rangeHeader) {
54
- debugView('Processing range request: %s for %s', rangeHeader, fileName);
55
- let meta;
56
- try {
57
- meta = await getFileMetadata(keyToUse, bucketName);
58
- } catch (err) {
59
- console.error('[mbkbucket] serveFileInline metadata error:', err);
60
- return res.status(500).json({ message: 'Failed to fetch file metadata', error: err.message });
61
- }
62
-
63
- if (!meta?.exists) return res.status(404).json({ message: 'File not found', key: keyToUse });
64
-
65
- const etag = `"${new Date(meta.LastModified || Date.now()).getTime()}-${meta.ContentLength || 0}"`;
66
- if (ifNoneMatch === etag) return res.status(304).end();
67
-
68
- const total = Number(meta.ContentLength);
69
- if (total) {
70
- const range = parseRangeHeader(rangeHeader, total);
71
- if (!range) {
72
- res.status(416).setHeader('Content-Range', `bytes */${total}`);
73
- return res.end();
74
- }
75
-
76
- const { start, end, size: chunksize } = range;
77
- try {
78
- const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, bucketName });
79
- if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
80
- debugView('Range size mismatch for %s: expected=%s got=%s', keyToUse, chunksize, rangeResult.ContentLength);
81
- }
82
-
83
- debugView('Serving range bytes %s-%s/%s (%s bytes) for %s', start, end, total, chunksize, fileName);
84
-
85
- res.status(206);
86
- res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
87
- res.setHeader('Content-Length', chunksize);
88
- res.setHeader('Content-Type', contentType);
89
- res.setHeader('Cache-Control', buildCacheControl(contentType, { supportsRanges: true, publicCache }));
90
- res.setHeader('ETag', etag);
91
- res.setHeader('Accept-Ranges', 'bytes');
92
- if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
93
- 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);
98
- } catch (rangeError) {
99
- console.error('Range request failed:', rangeError);
100
- }
101
- }
102
- }
103
-
104
- // Standard path: full-file download
105
- const downloadOptions = {
106
- ...(ifNoneMatch && { ifNoneMatch }),
107
- ...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) }),
108
- bucketName
109
- };
110
-
111
- if (isPdf) debugView('Serving full PDF (no range header) for %s', fileName);
112
-
113
- let result;
114
- try {
115
- result = await downloadFile(keyToUse, downloadOptions);
116
- } catch (err) {
117
- if (err.name === 'NoSuchKey' || err.name === 'NotFound' || err.message?.includes('File not found')) {
118
- return res.status(404).json({ message: 'File not found', key: keyToUse });
119
- }
120
- if (err.name === 'AccessDenied' || err.message?.includes('Access denied')) {
121
- return res.status(403).json({ message: 'Access denied' });
122
- }
123
- console.error('[mbkbucket] serveFileInline download error:', err);
124
- return res.status(500).json({ message: 'Failed to fetch file', error: err.message });
125
- }
126
-
127
- if (result?.notModified) return res.status(304).end();
128
-
129
- const etag = `"${new Date(result.LastModified || Date.now()).getTime()}-${result.ContentLength || 0}"`;
130
- if (ifNoneMatch === etag) return res.status(304).end();
131
-
132
- res.setHeader('Content-Type', contentType);
133
- res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
134
- res.setHeader('ETag', etag);
135
- res.setHeader('X-Content-Type-Options', 'nosniff');
136
- res.setHeader('X-Frame-Options', 'SAMEORIGIN');
137
- if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
138
- if (result.ContentLength) res.setHeader('Content-Length', result.ContentLength);
139
- if (result.LastModified) res.setHeader('Last-Modified', new Date(result.LastModified).toUTCString());
140
- if (fileExtension === 'html' || fileExtension === 'htm') {
141
- res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
142
- }
143
- if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
144
- res.setHeader('Cache-Control', buildCacheControl(contentType, { isStaticAsset, supportsRanges, publicCache }));
145
- res.status(200);
146
-
147
- const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
148
- attachStreamCleanup(req, res, result.Body, timeoutDuration);
149
- result.Body.pipe(res);
150
- }
151
-
152
- export async function viewFile(req, res) {
153
- try {
154
- const key = normalizeKeyParam(req.params.key);
155
- let keyToUse;
156
- try {
157
- keyToUse = ensureKeyHasAppPrefix(key);
158
- } catch (e) {
159
- return res.status(400).json({ message: e.message });
160
- }
161
-
162
- await serveFileInline(req, res, keyToUse, { noindex: true, bucketName: req.activeBucket, publicCache: false });
163
- } catch (error) {
164
- console.error("Error viewing file:", error);
165
- if (error.name === 'NoSuchKey' || error.name === 'NotFound' || error.message?.includes('File not found')) {
166
- res.status(404).json({ message: "File not found", key: req.params.key });
167
- } else if (error.name === 'AccessDenied' || error.message?.includes('Access denied')) {
168
- res.status(403).json({ message: "Access denied" });
169
- } else {
170
- res.status(500).json({ message: "View failed", error: error.message });
171
- }
172
- }
173
- }
174
-
175
- export async function playerPage(req, res) {
176
- try {
177
- const key = normalizeKeyParam(req.params.key);
178
- let keyToUse;
179
- try {
180
- keyToUse = ensureKeyHasAppPrefix(key);
181
- } catch (e) {
182
- return res.status(400).send(e.message);
183
- }
184
-
185
- const html = renderPlayerPage(
186
- escapeHtml(getBaseName(keyToUse)),
187
- encodeURIComponent(keyToUse),
188
- `?bucket=${encodeURIComponent(req.activeBucket)}`
189
- );
190
-
191
- res.setHeader('Content-Type', 'text/html; charset=utf-8');
192
- res.setHeader('X-Content-Type-Options', 'nosniff');
193
- return res.status(200).send(html);
194
- } catch (err) {
195
- console.error('Error rendering player page:', err);
196
- return res.status(500).send('Failed to render player');
197
- }
198
- }
199
-
200
- export async function publicView(req, res) {
201
- try {
202
- const bucketName = req.query?.bucket ? resolveStorageName(req.query.bucket) : resolveStorageName();
203
- const key = normalizeKeyParam(req.params.key);
204
-
205
- if (!key || key.endsWith('/')) {
206
- return res.status(403).json({ message: 'Directory indexing is not allowed' });
207
- }
208
-
209
- let keyToUse;
210
- try {
211
- keyToUse = ensureKeyHasAppPrefix(key);
212
- } catch (e) {
213
- return res.status(400).json({ message: e.message });
214
- }
215
-
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
- await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: true });
229
- } catch (error) {
230
- console.error('[mbkbucket] p_view error:', error);
231
- res.status(500).json({ message: 'Internal server error', error: error.message });
232
- }
233
- }
1
+ import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, resolveStorageName, getStorageProvider } from "../services/storage.service.js";
2
+ import { mbkbucketVar } from "../config/index.js";
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
+ import { createLogger } from "#logger";
5
+
6
+ const debugView = createLogger('view');
7
+
8
+ function attachStreamCleanup(req, res, stream, timeoutMs = 120000) {
9
+ if (!stream || typeof stream.on !== 'function') return;
10
+ const cleanup = () => destroyStream(stream);
11
+
12
+ stream.on('error', (err) => {
13
+ console.error('Stream error:', err);
14
+ cleanup();
15
+ if (!res.headersSent) res.status(500).end('Stream error');
16
+ });
17
+
18
+ req.on('close', cleanup);
19
+ req.on('aborted', cleanup);
20
+ req.setTimeout(timeoutMs, () => {
21
+ console.error('Request timeout for stream');
22
+ cleanup();
23
+ if (!res.headersSent) res.status(408).end('Request timeout');
24
+ });
25
+ res.on('finish', cleanup);
26
+ res.on('close', cleanup);
27
+ }
28
+
29
+
30
+ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName, publicCache = false } = {}) {
31
+ const fileName = getBaseName(keyToUse);
32
+ const fileExtension = getFileExt(fileName);
33
+
34
+ debugView('Request for %s (type: %s, range: %s)', fileName, fileExtension, req.headers.range || 'none');
35
+
36
+ if (!VIEWABLE_TYPES.has(fileExtension)) {
37
+ return res.status(415).json({
38
+ message: 'File type not supported for viewing',
39
+ supportedTypes: VIEWABLE_TYPES_SORTED
40
+ });
41
+ }
42
+
43
+ const ifNoneMatch = req.headers['if-none-match'];
44
+ const ifModifiedSince = req.headers['if-modified-since'];
45
+ const rangeHeader = req.headers.range;
46
+
47
+ const isStaticAsset = STATIC_ASSET_TYPES.has(fileExtension);
48
+ const isVideo = VIDEO_TYPES.has(fileExtension);
49
+ const isAudio = AUDIO_TYPES.has(fileExtension);
50
+ const isPdf = fileExtension === 'pdf';
51
+ const supportsRanges = isVideo || isAudio || isPdf;
52
+ const contentType = getMimeType(fileName);
53
+ const safeAsciiName = fileName.replace(/["\\]/g, '').replace(/[^\x20-\x7E]/g, '_');
54
+ const encodedName = encodeURIComponent(fileName);
55
+
56
+ // Fast path for range requests (video / audio / PDF streaming)
57
+ if (supportsRanges && rangeHeader) {
58
+ debugView('Processing range request: %s for %s', rangeHeader, fileName);
59
+ let meta;
60
+ try {
61
+ meta = await getFileMetadata(keyToUse, bucketName);
62
+ } catch (err) {
63
+ console.error('[mbkbucket] serveFileInline metadata error:', err);
64
+ return res.status(500).json({ message: 'Failed to fetch file metadata', error: err.message });
65
+ }
66
+
67
+ if (!meta?.exists) return res.status(404).json({ message: 'File not found', key: keyToUse });
68
+
69
+ const etag = `"${new Date(meta.LastModified || Date.now()).getTime()}-${meta.ContentLength || 0}"`;
70
+ if (ifNoneMatch === etag) return res.status(304).end();
71
+
72
+ const total = Number(meta.ContentLength);
73
+ if (total) {
74
+ const range = parseRangeHeader(rangeHeader, total);
75
+ if (!range) {
76
+ res.status(416).setHeader('Content-Range', `bytes */${total}`);
77
+ return res.end();
78
+ }
79
+
80
+ const { start, end, size: chunksize } = range;
81
+ try {
82
+ const rangeResult = await downloadFile(keyToUse, { range: `bytes=${start}-${end}`, bucketName });
83
+ if (typeof rangeResult.ContentLength === 'number' && rangeResult.ContentLength !== chunksize) {
84
+ debugView('Range size mismatch for %s: expected=%s got=%s', keyToUse, chunksize, rangeResult.ContentLength);
85
+ }
86
+
87
+ debugView('Serving range bytes %s-%s/%s (%s bytes) for %s', start, end, total, chunksize, fileName);
88
+
89
+ res.status(206);
90
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
91
+ res.setHeader('Content-Length', chunksize);
92
+ res.setHeader('Content-Type', contentType);
93
+ res.setHeader('Content-Disposition', `inline; filename="${safeAsciiName}"; filename*=UTF-8''${encodedName}`);
94
+ res.setHeader('Cache-Control', buildCacheControl(contentType, { supportsRanges: true, publicCache }));
95
+ res.setHeader('ETag', etag);
96
+ res.setHeader('Accept-Ranges', 'bytes');
97
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
98
+ if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
99
+ res.setHeader('X-Content-Type-Options', 'nosniff');
100
+ const stream = rangeResult.Body || rangeResult.stream;
101
+ attachStreamCleanup(req, res, stream);
102
+ return stream.pipe(res);
103
+ } catch (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;
109
+ }
110
+ }
111
+ }
112
+
113
+
114
+ // Standard path: full-file download
115
+ const downloadOptions = {
116
+ ...(ifNoneMatch && { ifNoneMatch }),
117
+ ...(ifModifiedSince && { ifModifiedSince: new Date(ifModifiedSince) }),
118
+ bucketName
119
+ };
120
+
121
+ if (isPdf) debugView('Serving full PDF (no range header) for %s', fileName);
122
+
123
+ let result;
124
+ try {
125
+ result = await downloadFile(keyToUse, downloadOptions);
126
+ } catch (err) {
127
+ if (err.name === 'NoSuchKey' || err.name === 'NotFound' || err.message?.includes('File not found')) {
128
+ return res.status(404).json({ message: 'File not found', key: keyToUse });
129
+ }
130
+ if (err.name === 'AccessDenied' || err.message?.includes('Access denied')) {
131
+ return res.status(403).json({ message: 'Access denied' });
132
+ }
133
+ console.error('[mbkbucket] serveFileInline download error:', err);
134
+ return res.status(500).json({ message: 'Failed to fetch file', error: err.message });
135
+ }
136
+
137
+ if (result?.notModified) return res.status(304).end();
138
+
139
+ const etag = `"${new Date(result.LastModified || Date.now()).getTime()}-${result.ContentLength || 0}"`;
140
+ if (ifNoneMatch === etag) return res.status(304).end();
141
+
142
+ res.setHeader('Content-Type', contentType);
143
+ res.setHeader('Content-Disposition', `inline; filename="${safeAsciiName}"; filename*=UTF-8''${encodedName}`);
144
+ res.setHeader('ETag', etag);
145
+ res.setHeader('X-Content-Type-Options', 'nosniff');
146
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
147
+ if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
148
+ if (result.ContentLength) res.setHeader('Content-Length', result.ContentLength);
149
+ if (result.LastModified) res.setHeader('Last-Modified', new Date(result.LastModified).toUTCString());
150
+
151
+ if (fileExtension === 'html' || fileExtension === 'htm') {
152
+ res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
153
+ }
154
+ if (supportsRanges) res.setHeader('Accept-Ranges', 'bytes');
155
+ res.setHeader('Cache-Control', buildCacheControl(contentType, { isStaticAsset, supportsRanges, publicCache }));
156
+ res.status(200);
157
+
158
+ const stream = result.Body || result.stream;
159
+ const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
160
+ attachStreamCleanup(req, res, stream, timeoutDuration);
161
+ stream.pipe(res);
162
+ }
163
+
164
+
165
+ export async function viewFile(req, res) {
166
+ try {
167
+ const key = normalizeKeyParam(req.params.key);
168
+ let keyToUse;
169
+ try {
170
+ keyToUse = ensureKeyHasAppPrefix(key);
171
+ } catch (e) {
172
+ return res.status(400).json({ message: e.message });
173
+ }
174
+
175
+ await serveFileInline(req, res, keyToUse, { noindex: true, bucketName: req.activeBucket, publicCache: false });
176
+ } catch (error) {
177
+ console.error("Error viewing file:", error);
178
+ if (error.name === 'NoSuchKey' || error.name === 'NotFound' || error.message?.includes('File not found')) {
179
+ res.status(404).json({ message: "File not found", key: req.params.key });
180
+ } else if (error.name === 'AccessDenied' || error.message?.includes('Access denied')) {
181
+ res.status(403).json({ message: "Access denied" });
182
+ } else {
183
+ res.status(500).json({ message: "View failed", error: error.message });
184
+ }
185
+ }
186
+ }
187
+
188
+ export async function playerPage(req, res) {
189
+ try {
190
+ const key = normalizeKeyParam(req.params.key);
191
+ let keyToUse;
192
+ try {
193
+ keyToUse = ensureKeyHasAppPrefix(key);
194
+ } catch (e) {
195
+ return res.status(400).send(e.message);
196
+ }
197
+
198
+ const html = renderPlayerPage(
199
+ escapeHtml(getBaseName(keyToUse)),
200
+ encodeURIComponent(keyToUse),
201
+ `?bucket=${encodeURIComponent(req.activeBucket)}`
202
+ );
203
+
204
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
205
+ res.setHeader('X-Content-Type-Options', 'nosniff');
206
+ return res.status(200).send(html);
207
+ } catch (err) {
208
+ console.error('Error rendering player page:', err);
209
+ return res.status(500).send('Failed to render player');
210
+ }
211
+ }
212
+
213
+ export async function publicView(req, res) {
214
+ try {
215
+ const bucketName = req.query?.bucket ? resolveStorageName(req.query.bucket) : resolveStorageName();
216
+ const key = normalizeKeyParam(req.params.key);
217
+
218
+ if (!key || key.endsWith('/')) {
219
+ return res.status(403).json({ message: 'Directory indexing is not allowed' });
220
+ }
221
+
222
+ let keyToUse;
223
+ try {
224
+ keyToUse = ensureKeyHasAppPrefix(key);
225
+ } catch (e) {
226
+ return res.status(400).json({ message: e.message });
227
+ }
228
+
229
+ await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: true });
230
+ } catch (error) {
231
+ console.error('[mbkbucket] p_view error:', error);
232
+ res.status(500).json({ message: 'Internal server error', error: error.message });
233
+ }
234
+ }
@@ -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();