mbkbucket 4.0.1 → 4.2.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/.env.example +3 -2
- package/README.md +96 -44
- package/index.js +4 -1
- package/lib/cli.js +1 -1
- package/lib/public/bucketadmin.js +255 -33
- package/lib/public/mbkbucket-helper-demo.html +0 -58
- package/lib/public/mbkbucket-helper.js +1 -9
- package/lib/src/app.js +10 -24
- package/lib/src/config/validation.js +6 -1
- package/lib/src/controllers/api.controller.js +34 -6
- package/lib/src/controllers/dashboard.controller.js +2 -2
- package/lib/src/controllers/info.controller.js +15 -14
- package/lib/src/controllers/view.controller.js +24 -22
- package/lib/src/core/metrics.js +134 -0
- package/lib/src/core/storage/capabilities.js +58 -0
- package/lib/src/core/storage/errors.js +0 -5
- package/lib/src/core/storage/gdrive-auth.js +0 -36
- package/lib/src/core/storage/gdrive-provider.js +103 -44
- package/lib/src/core/storage/index.js +4 -1
- package/lib/src/core/storage/local-provider.js +508 -0
- package/lib/src/core/storage/models.js +91 -50
- package/lib/src/core/storage/path-strategy.js +22 -2
- package/lib/src/core/storage/s3-provider.js +19 -38
- package/lib/src/core/storage/storage-manager.js +76 -53
- package/lib/src/core/storage/storage-provider.js +49 -120
- package/lib/src/middleware/error-handler.js +15 -52
- package/lib/src/middleware/rate-limiter.js +1 -1
- package/lib/src/routes/api.routes.js +4 -1
- package/lib/src/services/s3.service.js +2 -2
- package/lib/src/services/storage.service.js +118 -20
- package/lib/src/utils/cleanup-scheduler.js +91 -0
- package/lib/src/utils/helpers.js +8 -51
- package/package.json +4 -4
- package/views/bucket.hbs +0 -5
- package/views/bucketadmin_preview_modal.hbs +3 -3
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { pipeline } from "stream/promises";
|
|
2
2
|
import { listfiles, uploadFile, deleteFile, deleteFiles, deleteFolder, createFolder as createStorageFolder, downloadFile, getFileMetadata, fileExists, ensureKeyHasAppPrefix, ensurePrefix, createMultipartUpload, uploadPart, completeMultipartUpload, abortMultipartUpload, listIncompleteMultipartUploads, cleanupIncompleteMultipartUploads, getStorageProvider, copyFile, moveFile, checkHealth } from "../services/storage.service.js";
|
|
3
|
+
import { metricsRegistry } from "../core/metrics.js";
|
|
3
4
|
import { sendApiError, classifyApiError } from "../utils/errors.js";
|
|
4
|
-
import { getBaseName
|
|
5
|
+
import { getBaseName } from "mbkutils";
|
|
6
|
+
import { isSensitiveTextType, buildKey, normalizeKeyParam, getFolderPath, destroyStream } from "../utils/helpers.js";
|
|
5
7
|
import { createLogger } from "#logger";
|
|
6
8
|
|
|
9
|
+
|
|
7
10
|
const debugApi = createLogger('api');
|
|
8
11
|
|
|
9
12
|
function resolvePrefix(req, res, prefix) {
|
|
@@ -107,6 +110,9 @@ export async function listFiles(req, res) {
|
|
|
107
110
|
.map(p => p.Prefix);
|
|
108
111
|
nextContinuationToken = result.NextContinuationToken;
|
|
109
112
|
|
|
113
|
+
const provider = getStorageProvider(bucketName);
|
|
114
|
+
const capabilities = provider?.capabilities?.toJSON ? provider.capabilities.toJSON() : (provider?.capabilities || {});
|
|
115
|
+
|
|
110
116
|
return res.json({
|
|
111
117
|
success: true,
|
|
112
118
|
files,
|
|
@@ -120,7 +126,8 @@ export async function listFiles(req, res) {
|
|
|
120
126
|
nextContinuationToken,
|
|
121
127
|
mode: 'optimized',
|
|
122
128
|
totalFiles: -1,
|
|
123
|
-
paginationType: 'continuation-token'
|
|
129
|
+
paginationType: 'continuation-token',
|
|
130
|
+
capabilities
|
|
124
131
|
});
|
|
125
132
|
}
|
|
126
133
|
|
|
@@ -152,6 +159,9 @@ export async function listFiles(req, res) {
|
|
|
152
159
|
const currentPage = Math.max(1, parseInt(page, 10) || 1);
|
|
153
160
|
const paginatedFiles = files.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
|
154
161
|
|
|
162
|
+
const provider = getStorageProvider(bucketName);
|
|
163
|
+
const capabilities = provider?.capabilities?.toJSON ? provider.capabilities.toJSON() : (provider?.capabilities || {});
|
|
164
|
+
|
|
155
165
|
res.json({
|
|
156
166
|
success: true,
|
|
157
167
|
files: paginatedFiles,
|
|
@@ -164,7 +174,8 @@ export async function listFiles(req, res) {
|
|
|
164
174
|
hasPrevPage: currentPage > 1,
|
|
165
175
|
nextPage: currentPage + 1,
|
|
166
176
|
prevPage: Math.max(1, currentPage - 1),
|
|
167
|
-
totalFiles
|
|
177
|
+
totalFiles,
|
|
178
|
+
capabilities
|
|
168
179
|
});
|
|
169
180
|
} catch (error) {
|
|
170
181
|
console.error("Error listing files:", error);
|
|
@@ -366,8 +377,9 @@ export async function downloadFileHandler(req, res) {
|
|
|
366
377
|
if (aborted) return;
|
|
367
378
|
|
|
368
379
|
const result = await downloadFile(keyToUse, { bucketName: req.activeBucket });
|
|
380
|
+
const stream = result.Body || result.stream;
|
|
369
381
|
if (aborted) {
|
|
370
|
-
destroyStream(
|
|
382
|
+
destroyStream(stream);
|
|
371
383
|
return;
|
|
372
384
|
}
|
|
373
385
|
|
|
@@ -386,14 +398,15 @@ export async function downloadFileHandler(req, res) {
|
|
|
386
398
|
res.setHeader('Cache-Control', isSensitive ? 'private, no-store' : isLargeFile ? 'private, max-age=600, must-revalidate' : 'private, max-age=1800, must-revalidate');
|
|
387
399
|
|
|
388
400
|
try {
|
|
389
|
-
await pipeline(
|
|
401
|
+
await pipeline(stream, res);
|
|
390
402
|
} catch (pipeErr) {
|
|
391
403
|
if (aborted) {
|
|
392
|
-
destroyStream(
|
|
404
|
+
destroyStream(stream);
|
|
393
405
|
return;
|
|
394
406
|
}
|
|
395
407
|
throw pipeErr;
|
|
396
408
|
}
|
|
409
|
+
|
|
397
410
|
} catch (error) {
|
|
398
411
|
if (aborted) return;
|
|
399
412
|
console.error("Error downloading file:", error);
|
|
@@ -457,3 +470,18 @@ export async function getFileInfo(req, res) {
|
|
|
457
470
|
return sendApiError(res, error, 'Failed to get file info');
|
|
458
471
|
}
|
|
459
472
|
}
|
|
473
|
+
|
|
474
|
+
export async function getMetricsHandler(req, res) {
|
|
475
|
+
try {
|
|
476
|
+
const format = req.query.format || (req.headers.accept?.includes('application/json') ? 'json' : 'prometheus');
|
|
477
|
+
if (format === 'json') {
|
|
478
|
+
res.setHeader('Content-Type', 'application/json');
|
|
479
|
+
return res.json({ success: true, ...metricsRegistry.getStats() });
|
|
480
|
+
}
|
|
481
|
+
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
|
482
|
+
return res.send(metricsRegistry.toPrometheusText());
|
|
483
|
+
} catch (error) {
|
|
484
|
+
return sendApiError(res, error, 'Failed to generate metrics');
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
@@ -2,8 +2,8 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { resolveStorageName, getAvailableStorageNames, getStorageProvider, getStorageConfig } from "../services/storage.service.js";
|
|
4
4
|
import { storageManager } from "../core/storage/storage-manager.js";
|
|
5
|
-
import { renderPage,
|
|
6
|
-
import {
|
|
5
|
+
import { renderPage, commonHandlebarsHelpers } from "mbkutils";
|
|
6
|
+
import { mbkautheVar } from "mbkauthe";
|
|
7
7
|
import { mbkbucketVar } from "../config/index.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { renderPage
|
|
1
|
+
import { renderPage } from "mbkutils";
|
|
2
|
+
import { mbkautheVar } from "mbkauthe";
|
|
2
3
|
import { packageJson, getLatestVersion, appVersion, mbkbucketVar } from "../config/index.js";
|
|
3
4
|
import { getAvailableBucketNames, getStorageProvider } from "../services/storage.service.js";
|
|
4
5
|
import { storageManager } from "../core/storage/storage-manager.js";
|
|
5
6
|
|
|
6
|
-
async function getInfoData() {
|
|
7
|
+
async function getInfoData(requestedBucket) {
|
|
7
8
|
let latestVersion = 'unknown';
|
|
8
9
|
try {
|
|
9
10
|
latestVersion = (await getLatestVersion()) || 'unknown';
|
|
@@ -12,16 +13,21 @@ async function getInfoData() {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
const buckets = getAvailableBucketNames();
|
|
15
|
-
|
|
16
|
+
let defaultBucket = requestedBucket;
|
|
17
|
+
if (!defaultBucket || !buckets.includes(defaultBucket)) {
|
|
18
|
+
defaultBucket = storageManager.getDefaultConnectionName() || (buckets[0] ?? '');
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
let providerType = 'S3';
|
|
22
|
+
let capabilities = {};
|
|
17
23
|
if (defaultBucket) {
|
|
18
24
|
try {
|
|
19
25
|
const provider = getStorageProvider(defaultBucket);
|
|
20
26
|
providerType = (provider?.type || 's3').toUpperCase();
|
|
27
|
+
capabilities = provider?.capabilities?.toJSON ? provider.capabilities.toJSON() : (provider?.capabilities || {});
|
|
21
28
|
} catch {}
|
|
22
29
|
}
|
|
23
30
|
|
|
24
|
-
// Safe subset only - strictly prevent leaking passwords, DB connection strings, OAuth secrets, or storage credentials
|
|
25
31
|
const safeMbkautheVar = {
|
|
26
32
|
APP_NAME: mbkautheVar?.APP_NAME || mbkautheVar?.app_name || "mbkbucket",
|
|
27
33
|
};
|
|
@@ -41,24 +47,19 @@ async function getInfoData() {
|
|
|
41
47
|
mbkbucketVar: safeMbkbucketVar,
|
|
42
48
|
defaultBucket,
|
|
43
49
|
configuredBucketsCount: buckets.length,
|
|
44
|
-
providerType
|
|
50
|
+
providerType,
|
|
51
|
+
capabilities
|
|
45
52
|
};
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
/**
|
|
49
|
-
* Render the mbkbucket info page (Handlebars).
|
|
50
|
-
*/
|
|
51
55
|
export async function infoPage(req, res) {
|
|
52
56
|
renderPage(req, res, "mbkbucket_info.hbs", false, {
|
|
53
57
|
pageTitle: "MBKBucket Information",
|
|
54
58
|
ogUrl: "/mbkbucket/info",
|
|
55
|
-
...(await getInfoData())
|
|
59
|
+
...(await getInfoData(req.query?.bucket))
|
|
56
60
|
});
|
|
57
61
|
}
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
*/
|
|
62
|
-
export async function infoJson(_req, res) {
|
|
63
|
-
res.json(await getInfoData());
|
|
63
|
+
export async function infoJson(req, res) {
|
|
64
|
+
res.json(await getInfoData(req.query?.bucket));
|
|
64
65
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix,
|
|
1
|
+
import { downloadFile, getFileMetadata, ensureKeyHasAppPrefix, resolveStorageName, getStorageProvider } from "../services/storage.service.js";
|
|
2
2
|
import { mbkbucketVar } from "../config/index.js";
|
|
3
|
-
import { escapeHtml, getBaseName, getFileExt
|
|
3
|
+
import { escapeHtml, getBaseName, getFileExt } from "mbkutils";
|
|
4
|
+
import { getMimeType, normalizeKeyParam, destroyStream, VIEWABLE_TYPES, VIEWABLE_TYPES_SORTED, STATIC_ASSET_TYPES, VIDEO_TYPES, AUDIO_TYPES, buildCacheControl, parseRangeHeader, renderPlayerPage } from "../utils/helpers.js";
|
|
4
5
|
import { createLogger } from "#logger";
|
|
5
6
|
|
|
6
7
|
const debugView = createLogger('view');
|
|
7
8
|
|
|
8
9
|
function attachStreamCleanup(req, res, stream, timeoutMs = 120000) {
|
|
10
|
+
if (!stream || typeof stream.on !== 'function') return;
|
|
9
11
|
const cleanup = () => destroyStream(stream);
|
|
10
12
|
|
|
11
13
|
stream.on('error', (err) => {
|
|
@@ -25,6 +27,7 @@ function attachStreamCleanup(req, res, stream, timeoutMs = 120000) {
|
|
|
25
27
|
res.on('close', cleanup);
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
|
|
28
31
|
async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName, publicCache = false } = {}) {
|
|
29
32
|
const fileName = getBaseName(keyToUse);
|
|
30
33
|
const fileExtension = getFileExt(fileName);
|
|
@@ -48,6 +51,8 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
|
|
|
48
51
|
const isPdf = fileExtension === 'pdf';
|
|
49
52
|
const supportsRanges = isVideo || isAudio || isPdf;
|
|
50
53
|
const contentType = getMimeType(fileName);
|
|
54
|
+
const safeAsciiName = fileName.replace(/["\\]/g, '').replace(/[^\x20-\x7E]/g, '_');
|
|
55
|
+
const encodedName = encodeURIComponent(fileName);
|
|
51
56
|
|
|
52
57
|
// Fast path for range requests (video / audio / PDF streaming)
|
|
53
58
|
if (supportsRanges && rangeHeader) {
|
|
@@ -86,21 +91,27 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
|
|
|
86
91
|
res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
|
|
87
92
|
res.setHeader('Content-Length', chunksize);
|
|
88
93
|
res.setHeader('Content-Type', contentType);
|
|
94
|
+
res.setHeader('Content-Disposition', `inline; filename="${safeAsciiName}"; filename*=UTF-8''${encodedName}`);
|
|
89
95
|
res.setHeader('Cache-Control', buildCacheControl(contentType, { supportsRanges: true, publicCache }));
|
|
90
96
|
res.setHeader('ETag', etag);
|
|
91
97
|
res.setHeader('Accept-Ranges', 'bytes');
|
|
98
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
92
99
|
if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
|
|
93
100
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return rangeResult.Body.pipe(res);
|
|
101
|
+
const stream = rangeResult.Body || rangeResult.stream;
|
|
102
|
+
attachStreamCleanup(req, res, stream);
|
|
103
|
+
return stream.pipe(res);
|
|
98
104
|
} catch (rangeError) {
|
|
99
|
-
console.error('Range request failed:', rangeError);
|
|
105
|
+
console.error('[mbkbucket] Range request failed for %s:', keyToUse, rangeError);
|
|
106
|
+
if (!res.headersSent) {
|
|
107
|
+
return res.status(500).json({ message: 'Range stream failed', error: rangeError.message });
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
100
110
|
}
|
|
101
111
|
}
|
|
102
112
|
}
|
|
103
113
|
|
|
114
|
+
|
|
104
115
|
// Standard path: full-file download
|
|
105
116
|
const downloadOptions = {
|
|
106
117
|
...(ifNoneMatch && { ifNoneMatch }),
|
|
@@ -130,13 +141,14 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
|
|
|
130
141
|
if (ifNoneMatch === etag) return res.status(304).end();
|
|
131
142
|
|
|
132
143
|
res.setHeader('Content-Type', contentType);
|
|
133
|
-
res.setHeader('Content-Disposition', `inline; filename="${
|
|
144
|
+
res.setHeader('Content-Disposition', `inline; filename="${safeAsciiName}"; filename*=UTF-8''${encodedName}`);
|
|
134
145
|
res.setHeader('ETag', etag);
|
|
135
146
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
136
147
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
137
148
|
if (noindex) res.setHeader('X-Robots-Tag', 'noindex, nofollow');
|
|
138
149
|
if (result.ContentLength) res.setHeader('Content-Length', result.ContentLength);
|
|
139
150
|
if (result.LastModified) res.setHeader('Last-Modified', new Date(result.LastModified).toUTCString());
|
|
151
|
+
|
|
140
152
|
if (fileExtension === 'html' || fileExtension === 'htm') {
|
|
141
153
|
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'none'; object-src 'none';");
|
|
142
154
|
}
|
|
@@ -144,11 +156,13 @@ async function serveFileInline(req, res, keyToUse, { noindex = true, bucketName,
|
|
|
144
156
|
res.setHeader('Cache-Control', buildCacheControl(contentType, { isStaticAsset, supportsRanges, publicCache }));
|
|
145
157
|
res.status(200);
|
|
146
158
|
|
|
159
|
+
const stream = result.Body || result.stream;
|
|
147
160
|
const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
|
|
148
|
-
attachStreamCleanup(req, res,
|
|
149
|
-
|
|
161
|
+
attachStreamCleanup(req, res, stream, timeoutDuration);
|
|
162
|
+
stream.pipe(res);
|
|
150
163
|
}
|
|
151
164
|
|
|
165
|
+
|
|
152
166
|
export async function viewFile(req, res) {
|
|
153
167
|
try {
|
|
154
168
|
const key = normalizeKeyParam(req.params.key);
|
|
@@ -213,18 +227,6 @@ export async function publicView(req, res) {
|
|
|
213
227
|
return res.status(400).json({ message: e.message });
|
|
214
228
|
}
|
|
215
229
|
|
|
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
230
|
await serveFileInline(req, res, keyToUse, { noindex: true, bucketName, publicCache: true });
|
|
229
231
|
} catch (error) {
|
|
230
232
|
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
|
+
}
|
|
@@ -5,11 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
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
8
|
constructor(message, status = 500, code = 'INTERNAL_STORAGE_ERROR') {
|
|
14
9
|
super(message);
|
|
15
10
|
this.name = this.constructor.name;
|
|
@@ -15,17 +15,6 @@ export const GOOGLE_DRIVE_SCOPES = [
|
|
|
15
15
|
'https://www.googleapis.com/auth/drive.metadata.readonly'
|
|
16
16
|
];
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
* Generate Google OAuth 2.0 Authorization URL for user consent.
|
|
20
|
-
* @param {object} options
|
|
21
|
-
* @param {string} options.clientId
|
|
22
|
-
* @param {string} options.redirectUri
|
|
23
|
-
* @param {string} [options.state]
|
|
24
|
-
* @param {string[]} [options.scopes]
|
|
25
|
-
* @param {'consent'|'select_account'|'none'} [options.prompt='consent']
|
|
26
|
-
* @param {'offline'|'online'} [options.accessType='offline']
|
|
27
|
-
* @returns {string}
|
|
28
|
-
*/
|
|
29
18
|
export function getGoogleAuthUrl({
|
|
30
19
|
clientId,
|
|
31
20
|
redirectUri,
|
|
@@ -51,15 +40,6 @@ export function getGoogleAuthUrl({
|
|
|
51
40
|
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
|
52
41
|
}
|
|
53
42
|
|
|
54
|
-
/**
|
|
55
|
-
* Exchange an OAuth authorization code for tokens (access_token, refresh_token).
|
|
56
|
-
* @param {object} options
|
|
57
|
-
* @param {string} options.code
|
|
58
|
-
* @param {string} options.clientId
|
|
59
|
-
* @param {string} options.clientSecret
|
|
60
|
-
* @param {string} options.redirectUri
|
|
61
|
-
* @returns {Promise<{ access_token: string, refresh_token?: string, expires_in: number, token_type: string, scope: string, expiry_date: number }>}
|
|
62
|
-
*/
|
|
63
43
|
export async function exchangeCodeForTokens({
|
|
64
44
|
code,
|
|
65
45
|
clientId,
|
|
@@ -94,14 +74,6 @@ export async function exchangeCodeForTokens({
|
|
|
94
74
|
return { ...data, expiry_date };
|
|
95
75
|
}
|
|
96
76
|
|
|
97
|
-
/**
|
|
98
|
-
* Refresh an expired access token using a refresh token.
|
|
99
|
-
* @param {object} options
|
|
100
|
-
* @param {string} options.clientId
|
|
101
|
-
* @param {string} options.clientSecret
|
|
102
|
-
* @param {string} options.refreshToken
|
|
103
|
-
* @returns {Promise<{ access_token: string, expires_in: number, expiry_date: number, token_type: string }>}
|
|
104
|
-
*/
|
|
105
77
|
export async function refreshGoogleAccessToken({
|
|
106
78
|
clientId,
|
|
107
79
|
clientSecret,
|
|
@@ -134,14 +106,6 @@ export async function refreshGoogleAccessToken({
|
|
|
134
106
|
return { ...data, expiry_date };
|
|
135
107
|
}
|
|
136
108
|
|
|
137
|
-
/**
|
|
138
|
-
* Generate an OAuth2 access token from a Google Service Account using JWT signing.
|
|
139
|
-
* @param {object} options
|
|
140
|
-
* @param {string} options.clientEmail
|
|
141
|
-
* @param {string} options.privateKey
|
|
142
|
-
* @param {string[]} [options.scopes]
|
|
143
|
-
* @returns {Promise<{ access_token: string, expires_in: number, expiry_date: number }>}
|
|
144
|
-
*/
|
|
145
109
|
export async function getServiceAccountAccessToken({
|
|
146
110
|
clientEmail,
|
|
147
111
|
privateKey,
|