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,52 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
code: "ROUTE_NOT_FOUND",
|
|
17
|
-
req,
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
res.status(404);
|
|
22
|
-
return renderPage(req, res, "Error/dError.hbs", false, {
|
|
23
|
-
...BASE_ERROR_DATA,
|
|
24
|
-
code: 404,
|
|
25
|
-
error: "Not Found",
|
|
26
|
-
message: "The requested page was not found.",
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function globalErrorHandler(err, req, res, _next) {
|
|
31
|
-
console.error(`[mbkbucket] ${err.stack || err}`);
|
|
32
|
-
const statusCode = Number(err.status || err.statusCode || 500);
|
|
33
|
-
|
|
34
|
-
if (isJsonRequest(req)) {
|
|
35
|
-
return sendError(res, err, {
|
|
36
|
-
statusCode,
|
|
37
|
-
req,
|
|
38
|
-
details: process.env.NODE_ENV !== 'production' ? err.stack : undefined,
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const sanitizedDetails = err.message ? sanitizeErrorDetails(err.message) : undefined;
|
|
43
|
-
|
|
44
|
-
res.status(statusCode);
|
|
45
|
-
return renderPage(req, res, "Error/dError.hbs", false, {
|
|
46
|
-
...BASE_ERROR_DATA,
|
|
47
|
-
code: statusCode,
|
|
48
|
-
error: statusCode >= 500 ? "Internal app Error" : (err.name || "Client Error"),
|
|
49
|
-
message: err.message || "An unexpected error occurred on the app.",
|
|
50
|
-
...(sanitizedDetails && { details: sanitizedDetails }),
|
|
51
|
-
});
|
|
52
|
-
}
|
|
1
|
+
import { createNotFoundHandler, createErrorHandler } from "mbkutils";
|
|
2
|
+
|
|
3
|
+
export const notFoundHandler = createNotFoundHandler({
|
|
4
|
+
appName: "mbkbucket",
|
|
5
|
+
defaultPage: "/",
|
|
6
|
+
defaultPageName: "Home",
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const globalErrorHandler = createErrorHandler({
|
|
10
|
+
appName: "mbkbucket",
|
|
11
|
+
defaultPage: "/",
|
|
12
|
+
defaultPageName: "Home",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export default { notFoundHandler, globalErrorHandler };
|
|
@@ -2,7 +2,7 @@ import express from "express";
|
|
|
2
2
|
import { sessRole } from "mbkauthe";
|
|
3
3
|
import multer from "multer";
|
|
4
4
|
import { requireBucketApi } from "../middleware/bucket-resolver.js";
|
|
5
|
-
import { cleanupUploads, listIncompleteUploads, listFiles, uploadSingleFile, initiateMultipartUpload, uploadChunk, completeUpload, abortUpload, createFolder, deleteItems, downloadFileHandler, moveItem, copyItem, getHealthStatus, getFileInfo } from "../controllers/api.controller.js";
|
|
5
|
+
import { cleanupUploads, listIncompleteUploads, listFiles, uploadSingleFile, initiateMultipartUpload, uploadChunk, completeUpload, abortUpload, createFolder, deleteItems, downloadFileHandler, moveItem, copyItem, getHealthStatus, getFileInfo, getMetricsHandler } from "../controllers/api.controller.js";
|
|
6
6
|
|
|
7
7
|
const MAX_CHUNK_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
8
8
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_CHUNK_SIZE } });
|
|
@@ -38,6 +38,8 @@ export function createApiRoutes({
|
|
|
38
38
|
apiRouter.get('/api/files', viewAuth, listFiles);
|
|
39
39
|
apiRouter.get('/api/health', viewAuth, getHealthStatus);
|
|
40
40
|
apiRouter.get('/api/file-info/*key', viewAuth, getFileInfo);
|
|
41
|
+
apiRouter.get('/api/metrics', viewAuth, getMetricsHandler);
|
|
42
|
+
apiRouter.get('/metrics', viewAuth, getMetricsHandler);
|
|
41
43
|
apiRouter.post('/upload', uploadAuth, upload.single('file'), uploadSingleFile);
|
|
42
44
|
apiRouter.post('/upload-init', uploadAuth, initiateMultipartUpload);
|
|
43
45
|
apiRouter.post('/upload-chunk', uploadAuth, uploadChunkMiddleware, uploadChunk);
|
|
@@ -52,6 +54,7 @@ export function createApiRoutes({
|
|
|
52
54
|
return apiRouter;
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
|
|
55
58
|
const router = createApiRoutes();
|
|
56
59
|
|
|
57
60
|
export default router;
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { storageManager } from "../core/storage/storage-manager.js";
|
|
10
|
-
import { getAvailableBucketNames, resolveBucketName, getAppName, ensureKeyHasAppPrefix, ensurePrefix, uploadFile, downloadFile, deleteFile, deleteFiles, deleteFolder, listfiles, getFileMetadata, fileExists, getFileSize,
|
|
10
|
+
import { getAvailableBucketNames, resolveBucketName, getAppName, ensureKeyHasAppPrefix, ensurePrefix, uploadFile, downloadFile, deleteFile, deleteFiles, deleteFolder, listfiles, getFileMetadata, fileExists, getFileSize, createMultipartUpload, uploadPart, completeMultipartUpload, abortMultipartUpload, listIncompleteMultipartUploads, cleanupIncompleteMultipartUploads, checkHealth, runHealthCheck } from "./storage.service.js";
|
|
11
11
|
|
|
12
|
-
export { getAvailableBucketNames, resolveBucketName, getAppName, ensureKeyHasAppPrefix, ensurePrefix, uploadFile, downloadFile, deleteFile, deleteFiles, deleteFolder, listfiles, getFileMetadata, fileExists, getFileSize,
|
|
12
|
+
export { getAvailableBucketNames, resolveBucketName, getAppName, ensureKeyHasAppPrefix, ensurePrefix, uploadFile, downloadFile, deleteFile, deleteFiles, deleteFolder, listfiles, getFileMetadata, fileExists, getFileSize, createMultipartUpload, uploadPart, completeMultipartUpload, abortMultipartUpload, listIncompleteMultipartUploads, cleanupIncompleteMultipartUploads, checkHealth, runHealthCheck };
|
|
13
13
|
|
|
14
14
|
export function getBucketConfig(bucketName) {
|
|
15
15
|
return storageManager.getConnectionConfig(bucketName);
|
|
@@ -8,8 +8,9 @@ import dotenv from "dotenv";
|
|
|
8
8
|
import { storageManager } from "../core/storage/storage-manager.js";
|
|
9
9
|
import { PathStrategy } from "../core/storage/path-strategy.js";
|
|
10
10
|
import { StorageValidationError } from "../core/storage/errors.js";
|
|
11
|
+
import { metricsRegistry } from "../core/metrics.js";
|
|
11
12
|
import { createLogger } from "#logger";
|
|
12
|
-
import { trimSlashes } from "
|
|
13
|
+
import { trimSlashes } from "mbkutils";
|
|
13
14
|
|
|
14
15
|
dotenv.config();
|
|
15
16
|
const debugStorage = createLogger('storage-service');
|
|
@@ -64,22 +65,55 @@ export async function runHealthCheck(connectionName) {
|
|
|
64
65
|
export async function uploadFile(key, fileBuffer, contentType, options = {}) {
|
|
65
66
|
if (!key || !fileBuffer) throw new StorageValidationError('Key and file buffer are required');
|
|
66
67
|
const keyToUpload = ensureKeyHasAppPrefix(key);
|
|
67
|
-
const
|
|
68
|
-
|
|
68
|
+
const connName = options.bucketName || options.connectionName;
|
|
69
|
+
const provider = getStorageProvider(connName);
|
|
70
|
+
const start = Date.now();
|
|
71
|
+
try {
|
|
72
|
+
const result = await provider.uploadFile(keyToUpload, fileBuffer, contentType, options);
|
|
73
|
+
const duration = Date.now() - start;
|
|
74
|
+
const size = Buffer.isBuffer(fileBuffer) ? fileBuffer.length : (result.size || 0);
|
|
75
|
+
metricsRegistry.recordOperation('upload', { provider: provider.type, status: 'success', durationMs: duration, bytes: size });
|
|
76
|
+
storageManager.emit('file:uploaded', { key: keyToUpload, provider: provider.type, connectionName: connName, size, timestamp: new Date().toISOString() });
|
|
77
|
+
return result;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
metricsRegistry.recordOperation('upload', { provider: provider?.type || 'unknown', status: 'error', durationMs: Date.now() - start });
|
|
80
|
+
metricsRegistry.recordError('upload', provider?.type || 'unknown', err.constructor?.name || 'Error');
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
export async function downloadFile(key, options = {}) {
|
|
72
86
|
if (!key) throw new StorageValidationError('Key is required');
|
|
73
87
|
const keyToDownload = ensureKeyHasAppPrefix(key);
|
|
74
|
-
const
|
|
75
|
-
|
|
88
|
+
const connName = options.bucketName || options.connectionName;
|
|
89
|
+
const provider = getStorageProvider(connName);
|
|
90
|
+
const start = Date.now();
|
|
91
|
+
try {
|
|
92
|
+
const result = await provider.downloadFile(keyToDownload, options);
|
|
93
|
+
metricsRegistry.recordOperation('download', { provider: provider.type, status: 'success', durationMs: Date.now() - start, bytes: result.ContentLength || 0 });
|
|
94
|
+
return result;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
metricsRegistry.recordOperation('download', { provider: provider?.type || 'unknown', status: 'error', durationMs: Date.now() - start });
|
|
97
|
+
metricsRegistry.recordError('download', provider?.type || 'unknown', err.constructor?.name || 'Error');
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
76
100
|
}
|
|
77
101
|
|
|
78
102
|
export async function deleteFile(key, connectionName) {
|
|
79
103
|
if (!key) throw new StorageValidationError('Key is required');
|
|
80
104
|
const keyToDelete = ensureKeyHasAppPrefix(key);
|
|
81
105
|
const provider = getStorageProvider(connectionName);
|
|
82
|
-
|
|
106
|
+
const start = Date.now();
|
|
107
|
+
try {
|
|
108
|
+
const result = await provider.deleteFile(keyToDelete);
|
|
109
|
+
metricsRegistry.recordOperation('delete', { provider: provider.type, status: 'success', durationMs: Date.now() - start });
|
|
110
|
+
storageManager.emit('file:deleted', { key: keyToDelete, provider: provider.type, connectionName, timestamp: new Date().toISOString() });
|
|
111
|
+
return result;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
metricsRegistry.recordOperation('delete', { provider: provider?.type || 'unknown', status: 'error', durationMs: Date.now() - start });
|
|
114
|
+
metricsRegistry.recordError('delete', provider?.type || 'unknown', err.constructor?.name || 'Error');
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
83
117
|
}
|
|
84
118
|
|
|
85
119
|
export async function deleteFiles(keys, connectionName) {
|
|
@@ -88,33 +122,82 @@ export async function deleteFiles(keys, connectionName) {
|
|
|
88
122
|
}
|
|
89
123
|
const keysToDelete = keys.map(k => ensureKeyHasAppPrefix(k));
|
|
90
124
|
const provider = getStorageProvider(connectionName);
|
|
91
|
-
|
|
125
|
+
const start = Date.now();
|
|
126
|
+
try {
|
|
127
|
+
const result = await provider.deleteFiles(keysToDelete);
|
|
128
|
+
metricsRegistry.recordOperation('deleteBatch', { provider: provider.type, status: 'success', durationMs: Date.now() - start });
|
|
129
|
+
storageManager.emit('files:deleted', { keys: keysToDelete, provider: provider.type, connectionName, timestamp: new Date().toISOString() });
|
|
130
|
+
return result;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
metricsRegistry.recordOperation('deleteBatch', { provider: provider?.type || 'unknown', status: 'error', durationMs: Date.now() - start });
|
|
133
|
+
metricsRegistry.recordError('deleteBatch', provider?.type || 'unknown', err.constructor?.name || 'Error');
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
92
136
|
}
|
|
93
137
|
|
|
94
138
|
export async function deleteFolder(prefix, connectionName) {
|
|
95
139
|
if (!prefix) throw new StorageValidationError('Prefix is required');
|
|
96
140
|
const effectivePrefix = `${ensurePrefix(trimSlashes(prefix))}/`;
|
|
97
141
|
const provider = getStorageProvider(connectionName);
|
|
98
|
-
|
|
142
|
+
const start = Date.now();
|
|
143
|
+
try {
|
|
144
|
+
const result = await provider.deleteFolder(effectivePrefix);
|
|
145
|
+
metricsRegistry.recordOperation('deleteFolder', { provider: provider.type, status: 'success', durationMs: Date.now() - start });
|
|
146
|
+
storageManager.emit('folder:deleted', { prefix: effectivePrefix, provider: provider.type, connectionName, timestamp: new Date().toISOString() });
|
|
147
|
+
return result;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
metricsRegistry.recordOperation('deleteFolder', { provider: provider?.type || 'unknown', status: 'error', durationMs: Date.now() - start });
|
|
150
|
+
metricsRegistry.recordError('deleteFolder', provider?.type || 'unknown', err.constructor?.name || 'Error');
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
99
153
|
}
|
|
100
154
|
|
|
101
155
|
export async function createFolder(prefix, connectionName, options = {}) {
|
|
102
156
|
const effectivePrefix = ensurePrefix(prefix);
|
|
103
157
|
const provider = getStorageProvider(connectionName);
|
|
104
|
-
|
|
158
|
+
const start = Date.now();
|
|
159
|
+
try {
|
|
160
|
+
const result = await provider.createFolder(effectivePrefix, options);
|
|
161
|
+
metricsRegistry.recordOperation('createFolder', { provider: provider.type, status: 'success', durationMs: Date.now() - start });
|
|
162
|
+
storageManager.emit('folder:created', { prefix: effectivePrefix, provider: provider.type, connectionName, timestamp: new Date().toISOString() });
|
|
163
|
+
return result;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
metricsRegistry.recordOperation('createFolder', { provider: provider?.type || 'unknown', status: 'error', durationMs: Date.now() - start });
|
|
166
|
+
metricsRegistry.recordError('createFolder', provider?.type || 'unknown', err.constructor?.name || 'Error');
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
105
169
|
}
|
|
106
170
|
|
|
107
171
|
export async function listfiles(prefix = '', options = {}) {
|
|
108
172
|
const effectivePrefix = ensurePrefix(prefix);
|
|
109
|
-
const
|
|
110
|
-
|
|
173
|
+
const connName = options.bucketName || options.connectionName;
|
|
174
|
+
const provider = getStorageProvider(connName);
|
|
175
|
+
const start = Date.now();
|
|
176
|
+
try {
|
|
177
|
+
const result = await provider.listFiles(effectivePrefix, options);
|
|
178
|
+
metricsRegistry.recordOperation('list', { provider: provider.type, status: 'success', durationMs: Date.now() - start });
|
|
179
|
+
return result;
|
|
180
|
+
} catch (err) {
|
|
181
|
+
metricsRegistry.recordOperation('list', { provider: provider?.type || 'unknown', status: 'error', durationMs: Date.now() - start });
|
|
182
|
+
metricsRegistry.recordError('list', provider?.type || 'unknown', err.constructor?.name || 'Error');
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
111
185
|
}
|
|
112
186
|
|
|
113
187
|
export async function getFileMetadata(key, connectionName) {
|
|
114
188
|
if (!key) throw new StorageValidationError('Key is required');
|
|
115
189
|
const keyToCheck = ensureKeyHasAppPrefix(key);
|
|
116
190
|
const provider = getStorageProvider(connectionName);
|
|
117
|
-
|
|
191
|
+
const start = Date.now();
|
|
192
|
+
try {
|
|
193
|
+
const result = await provider.getFileMetadata(keyToCheck);
|
|
194
|
+
metricsRegistry.recordOperation('metadata', { provider: provider.type, status: 'success', durationMs: Date.now() - start });
|
|
195
|
+
return result;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
metricsRegistry.recordOperation('metadata', { provider: provider?.type || 'unknown', status: 'error', durationMs: Date.now() - start });
|
|
198
|
+
metricsRegistry.recordError('metadata', provider?.type || 'unknown', err.constructor?.name || 'Error');
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
118
201
|
}
|
|
119
202
|
|
|
120
203
|
export async function fileExists(key, connectionName) {
|
|
@@ -135,26 +218,41 @@ export async function getFileSize(key, connectionName) {
|
|
|
135
218
|
}
|
|
136
219
|
}
|
|
137
220
|
|
|
138
|
-
export async function generateSignedUrl(key, operation = 'getObject', expiresIn = 3600, connectionName) {
|
|
139
|
-
const keyToUse = ensureKeyHasAppPrefix(key);
|
|
140
|
-
const provider = getStorageProvider(connectionName);
|
|
141
|
-
return provider.generateSignedUrl(keyToUse, operation, expiresIn);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
221
|
export async function copyFile(sourceKey, destKey, options = {}, connectionName) {
|
|
145
222
|
const src = ensureKeyHasAppPrefix(sourceKey);
|
|
146
223
|
const dst = ensureKeyHasAppPrefix(destKey);
|
|
147
224
|
const provider = getStorageProvider(connectionName);
|
|
148
|
-
|
|
225
|
+
const start = Date.now();
|
|
226
|
+
try {
|
|
227
|
+
const result = await provider.copyFile(src, dst, options);
|
|
228
|
+
metricsRegistry.recordOperation('copy', { provider: provider.type, status: 'success', durationMs: Date.now() - start });
|
|
229
|
+
storageManager.emit('file:copied', { sourceKey: src, destKey: dst, provider: provider.type, connectionName, timestamp: new Date().toISOString() });
|
|
230
|
+
return result;
|
|
231
|
+
} catch (err) {
|
|
232
|
+
metricsRegistry.recordOperation('copy', { provider: provider?.type || 'unknown', status: 'error', durationMs: Date.now() - start });
|
|
233
|
+
metricsRegistry.recordError('copy', provider?.type || 'unknown', err.constructor?.name || 'Error');
|
|
234
|
+
throw err;
|
|
235
|
+
}
|
|
149
236
|
}
|
|
150
237
|
|
|
151
238
|
export async function moveFile(sourceKey, destKey, options = {}, connectionName) {
|
|
152
239
|
const src = ensureKeyHasAppPrefix(sourceKey);
|
|
153
240
|
const dst = ensureKeyHasAppPrefix(destKey);
|
|
154
241
|
const provider = getStorageProvider(connectionName);
|
|
155
|
-
|
|
242
|
+
const start = Date.now();
|
|
243
|
+
try {
|
|
244
|
+
const result = await provider.moveFile(src, dst, options);
|
|
245
|
+
metricsRegistry.recordOperation('move', { provider: provider.type, status: 'success', durationMs: Date.now() - start });
|
|
246
|
+
storageManager.emit('file:moved', { sourceKey: src, destKey: dst, provider: provider.type, connectionName, timestamp: new Date().toISOString() });
|
|
247
|
+
return result;
|
|
248
|
+
} catch (err) {
|
|
249
|
+
metricsRegistry.recordOperation('move', { provider: provider?.type || 'unknown', status: 'error', durationMs: Date.now() - start });
|
|
250
|
+
metricsRegistry.recordError('move', provider?.type || 'unknown', err.constructor?.name || 'Error');
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
156
253
|
}
|
|
157
254
|
|
|
255
|
+
|
|
158
256
|
// ---------------------------------------------------------------------------
|
|
159
257
|
// Multipart Upload Helpers (For S3 / compatible providers)
|
|
160
258
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MBKBucket - Incomplete Multipart Upload Cleanup Scheduler
|
|
3
|
+
* Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
|
|
4
|
+
* Licensed under the MIT License.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { cleanupIncompleteMultipartUploads, getAvailableStorageNames } from "../services/storage.service.js";
|
|
8
|
+
import { createLogger } from "#logger";
|
|
9
|
+
|
|
10
|
+
const debugCleanup = createLogger('cleanup-scheduler');
|
|
11
|
+
|
|
12
|
+
export class CleanupScheduler {
|
|
13
|
+
constructor() {
|
|
14
|
+
this._timer = null;
|
|
15
|
+
this._running = false;
|
|
16
|
+
this._lastRun = null;
|
|
17
|
+
this._lastStats = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async runOnce({ olderThanDays = 7, connectionNames = null } = {}) {
|
|
21
|
+
const targets = connectionNames && Array.isArray(connectionNames) && connectionNames.length > 0
|
|
22
|
+
? connectionNames
|
|
23
|
+
: getAvailableStorageNames();
|
|
24
|
+
|
|
25
|
+
const summary = {
|
|
26
|
+
startedAt: new Date().toISOString(),
|
|
27
|
+
connectionsProcessed: 0,
|
|
28
|
+
totalAborted: 0,
|
|
29
|
+
errors: []
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
for (const name of targets) {
|
|
33
|
+
try {
|
|
34
|
+
const result = await cleanupIncompleteMultipartUploads(olderThanDays, '', name);
|
|
35
|
+
summary.connectionsProcessed++;
|
|
36
|
+
summary.totalAborted += (result.abortedCount || 0);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
debugCleanup('Cleanup error for connection %s: %s', name, err.message);
|
|
39
|
+
summary.errors.push({ connection: name, error: err.message });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
summary.finishedAt = new Date().toISOString();
|
|
44
|
+
this._lastRun = summary.finishedAt;
|
|
45
|
+
this._lastStats = summary;
|
|
46
|
+
debugCleanup('Cleanup cycle completed: %d uploads aborted across %d connections', summary.totalAborted, summary.connectionsProcessed);
|
|
47
|
+
return summary;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
start({ intervalHours = 24, olderThanDays = 7, connectionNames = null } = {}) {
|
|
51
|
+
if (this._running) return;
|
|
52
|
+
this._running = true;
|
|
53
|
+
|
|
54
|
+
const intervalMs = Math.max(60000, Number(intervalHours) * 3600 * 1000);
|
|
55
|
+
debugCleanup('Starting cleanup scheduler every %s hour(s)', intervalHours);
|
|
56
|
+
|
|
57
|
+
// Run first pass asynchronously
|
|
58
|
+
this.runOnce({ olderThanDays, connectionNames }).catch(err => {
|
|
59
|
+
debugCleanup('Initial cleanup run error: %s', err.message);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this._timer = setInterval(() => {
|
|
63
|
+
this.runOnce({ olderThanDays, connectionNames }).catch(err => {
|
|
64
|
+
debugCleanup('Scheduled cleanup run error: %s', err.message);
|
|
65
|
+
});
|
|
66
|
+
}, intervalMs);
|
|
67
|
+
|
|
68
|
+
if (this._timer.unref) {
|
|
69
|
+
this._timer.unref();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
stop() {
|
|
74
|
+
if (this._timer) {
|
|
75
|
+
clearInterval(this._timer);
|
|
76
|
+
this._timer = null;
|
|
77
|
+
}
|
|
78
|
+
this._running = false;
|
|
79
|
+
debugCleanup('Cleanup scheduler stopped');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getStatus() {
|
|
83
|
+
return {
|
|
84
|
+
running: this._running,
|
|
85
|
+
lastRun: this._lastRun,
|
|
86
|
+
lastStats: this._lastStats
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const cleanupScheduler = new CleanupScheduler();
|
package/lib/src/utils/helpers.js
CHANGED
|
@@ -2,24 +2,9 @@
|
|
|
2
2
|
* Shared helper utilities used across controllers, services, and CLI.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import { trimSlashes, trimLeadingSlashes, getFileExt, nowIso, formatDate } from "mbkutils";
|
|
6
6
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
export function getBaseName(path = '') {
|
|
10
|
-
const value = String(path ?? '');
|
|
11
|
-
const lastSlash = value.lastIndexOf('/');
|
|
12
|
-
return lastSlash === -1 ? value : value.slice(lastSlash + 1);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function getFileExt(fileName = '') {
|
|
16
|
-
const name = getBaseName(fileName);
|
|
17
|
-
const dotIndex = name.lastIndexOf('.');
|
|
18
|
-
return dotIndex === -1 ? '' : name.slice(dotIndex + 1).toLowerCase();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export const trimSlashes = (str = '') => String(str ?? '').replace(/^\/+|\/+$/g, '');
|
|
22
|
-
export const trimLeadingSlashes = (str = '') => String(str ?? '').replace(/^\/+/, '');
|
|
7
|
+
export { nowIso, formatDate };
|
|
23
8
|
|
|
24
9
|
export function getFolderPath(key = '') {
|
|
25
10
|
const s = String(key ?? '');
|
|
@@ -35,8 +20,6 @@ export function buildKey(prefix, fileName) {
|
|
|
35
20
|
return cleanPrefix ? `${cleanPrefix}/${cleanName}` : cleanName;
|
|
36
21
|
}
|
|
37
22
|
|
|
38
|
-
export const nowIso = () => new Date().toISOString();
|
|
39
|
-
|
|
40
23
|
export function formatBytes(bytes) {
|
|
41
24
|
if (!bytes) return '0 B';
|
|
42
25
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
@@ -44,14 +27,6 @@ export function formatBytes(bytes) {
|
|
|
44
27
|
return `${(bytes / 1024 ** i).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
|
45
28
|
}
|
|
46
29
|
|
|
47
|
-
export function formatDate(iso) {
|
|
48
|
-
try {
|
|
49
|
-
return new Date(iso).toLocaleString();
|
|
50
|
-
} catch {
|
|
51
|
-
return iso || '—';
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
30
|
export async function streamToBuffer(stream) {
|
|
56
31
|
if (Buffer.isBuffer(stream)) return stream;
|
|
57
32
|
const chunks = [];
|
|
@@ -67,12 +42,14 @@ export async function streamToBuffer(stream) {
|
|
|
67
42
|
}
|
|
68
43
|
|
|
69
44
|
export function destroyStream(stream) {
|
|
70
|
-
if (typeof stream
|
|
71
|
-
|
|
72
|
-
|
|
45
|
+
if (stream && typeof stream.destroy === 'function' && !stream.destroyed) {
|
|
46
|
+
try {
|
|
47
|
+
stream.destroy();
|
|
48
|
+
} catch {}
|
|
73
49
|
}
|
|
74
50
|
}
|
|
75
51
|
|
|
52
|
+
|
|
76
53
|
// ---------------------------------------------------------------------------
|
|
77
54
|
// MIME type & file extension constants
|
|
78
55
|
// ---------------------------------------------------------------------------
|
|
@@ -136,8 +113,6 @@ export function buildCacheControl(contentType, { isStaticAsset = false, supports
|
|
|
136
113
|
// Range header parsing
|
|
137
114
|
// ---------------------------------------------------------------------------
|
|
138
115
|
|
|
139
|
-
const DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB
|
|
140
|
-
|
|
141
116
|
export function parseRangeHeader(rangeHeader, totalSize) {
|
|
142
117
|
if (!totalSize || totalSize <= 0) return null;
|
|
143
118
|
|
|
@@ -156,7 +131,7 @@ export function parseRangeHeader(rangeHeader, totalSize) {
|
|
|
156
131
|
} else {
|
|
157
132
|
start = parseInt(startStr, 10);
|
|
158
133
|
if (isNaN(start) || start < 0) return null;
|
|
159
|
-
end = endStr === '' ?
|
|
134
|
+
end = endStr === '' ? totalSize - 1 : parseInt(endStr, 10);
|
|
160
135
|
if (isNaN(end) || end < start) return null;
|
|
161
136
|
}
|
|
162
137
|
|
|
@@ -192,22 +167,4 @@ export const renderPlayerPage = (safeFileName, encodedKey, bucketQuery) => `<!do
|
|
|
192
167
|
</body>
|
|
193
168
|
</html>`;
|
|
194
169
|
|
|
195
|
-
// ---------------------------------------------------------------------------
|
|
196
|
-
// Common Handlebars helpers
|
|
197
|
-
// ---------------------------------------------------------------------------
|
|
198
170
|
|
|
199
|
-
export const commonHandlebarsHelpers = {
|
|
200
|
-
eq: (a, b) => a === b,
|
|
201
|
-
neq: (a, b) => a !== b,
|
|
202
|
-
or: (...args) => args.slice(0, -1).some(Boolean),
|
|
203
|
-
and: (...args) => args.slice(0, -1).every(Boolean),
|
|
204
|
-
not: val => !val,
|
|
205
|
-
gt: (a, b) => a > b,
|
|
206
|
-
gte: (a, b) => a >= b,
|
|
207
|
-
lt: (a, b) => a < b,
|
|
208
|
-
lte: (a, b) => a <= b,
|
|
209
|
-
includes: (arr, val) => Array.isArray(arr) && arr.includes(val),
|
|
210
|
-
json: obj => JSON.stringify(obj, null, 2),
|
|
211
|
-
jsonStringify: obj => JSON.stringify(obj),
|
|
212
|
-
getInitials: username => username ? username.split(/[._]/).map(p => p.charAt(0)).join('').toUpperCase().slice(0, 2) : '?',
|
|
213
|
-
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mbkbucket",
|
|
3
|
-
"version": "4.0
|
|
4
|
-
"description": "Flexible S3/R2 bucket management — library, Express router, and standalone server CLI for mbktech.org applications. Provides upload, download, listing, multipart utilities,
|
|
3
|
+
"version": "4.2.0",
|
|
4
|
+
"description": "Flexible S3/R2/Google Drive/Local bucket management — library, Express router, and standalone server CLI for mbktech.org applications. Provides upload, download, listing, multipart utilities, and a standalone server runner with built-in configuration validation and health checks",
|
|
5
5
|
"author": "Muhammad Bin Khalid",
|
|
6
6
|
"homepage": "https://github.com/MIbnEKhalid/mbkbucket#readme",
|
|
7
7
|
"license": "MIT",
|
|
@@ -23,14 +23,14 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@aws-sdk/client-s3": "^3.1127.0",
|
|
26
|
-
"@aws-sdk/s3-request-presigner": "^3.1127.0",
|
|
27
26
|
"compression": "^1.8.2",
|
|
28
27
|
"debug": "^4.4.3",
|
|
29
28
|
"dotenv": "^18.0.5",
|
|
30
29
|
"express": "^5.2.1",
|
|
31
30
|
"express-handlebars": "^9.0.1",
|
|
32
31
|
"express-rate-limit": "^7.5.1",
|
|
33
|
-
"mbkauthe": "
|
|
32
|
+
"mbkauthe": "latest",
|
|
33
|
+
"mbkutils": "latest",
|
|
34
34
|
"multer": "^2.4.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
package/views/bucket.hbs
CHANGED
|
@@ -12,12 +12,7 @@
|
|
|
12
12
|
<meta charset="UTF-8">
|
|
13
13
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
14
14
|
<title>{{#if page}}{{page}} - {{/if}}Web Portal</title>
|
|
15
|
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
16
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
17
|
-
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700;800&family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
18
15
|
<script src="/mbkauthe/main.js" defer></script>
|
|
19
|
-
<script src="https://portal.mbktech.org/qssets/js/auto-ids.js" defer></script>
|
|
20
|
-
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
|
21
16
|
<link rel="icon" type="image/x-icon" href="/icon.svg">
|
|
22
17
|
<style>
|
|
23
18
|
:root {
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
<div class="info-label">Public View URL</div>
|
|
72
72
|
<div class="info-value font-mono text-break d-flex align-items-center justify-content-between gap-2">
|
|
73
73
|
<span id="infoPublicUrl" class="text-truncate" style="user-select: all;">—</span>
|
|
74
|
-
<button type="button" class="btn btn-outline btn-sm flex-shrink-0" onclick="copyCurrentPreviewPublicLink()" title="Copy Public URL">
|
|
74
|
+
<button type="button" class="btn btn-outline btn-sm flex-shrink-0" onclick="copyCurrentPreviewPublicLink(this)" title="Copy Public URL">
|
|
75
75
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 2h8a1 1 0 011 1v8M3 5h8a1 1 0 011 1v8a1 1 0 01-1 1H3a1 1 0 01-1-1V6a1 1 0 011-1z"/></svg>
|
|
76
76
|
Copy
|
|
77
77
|
</button>
|
|
@@ -84,11 +84,11 @@
|
|
|
84
84
|
</div>
|
|
85
85
|
<div class="modal-footer preview-footer">
|
|
86
86
|
<div class="preview-actions">
|
|
87
|
-
<button type="button" class="btn btn-outline btn-sm" id="previewCopyLinkBtn" onclick="copyCurrentPreviewLink()">
|
|
87
|
+
<button type="button" class="btn btn-outline btn-sm" id="previewCopyLinkBtn" onclick="copyCurrentPreviewLink(this)">
|
|
88
88
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 2h8a1 1 0 011 1v8M3 5h8a1 1 0 011 1v8a1 1 0 01-1 1H3a1 1 0 01-1-1V6a1 1 0 011-1z"/></svg>
|
|
89
89
|
Copy Link
|
|
90
90
|
</button>
|
|
91
|
-
<button type="button" class="btn btn-outline btn-sm" id="previewCopyPublicLinkBtn" onclick="copyCurrentPreviewPublicLink()" title="Copy publicly accessible link" style="{{#unless publicViewEnabled}}display: none;{{/unless}}">
|
|
91
|
+
<button type="button" class="btn btn-outline btn-sm" id="previewCopyPublicLinkBtn" onclick="copyCurrentPreviewPublicLink(this)" title="Copy publicly accessible link" style="{{#unless publicViewEnabled}}display: none;{{/unless}}">
|
|
92
92
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.25"/><path d="M1.75 8h12.5M8 1.75a10.5 10.5 0 010 12.5 10.5 10.5 0 010-12.5z"/></svg>
|
|
93
93
|
Copy Public Link
|
|
94
94
|
</button>
|