mbkbucket 2.0.0 → 2.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.
- package/index.js +9 -9
- package/lib/config/index.js +27 -11
- package/lib/config/validation.js +5 -2
- package/lib/debug.js +12 -0
- package/lib/routes/api.js +41 -51
- package/lib/routes/view.js +37 -17
- package/lib/s3.js +41 -28
- package/package.json +5 -4
- package/public/bucketadmin.css +124 -1
- package/public/bucketadmin.js +177 -86
- package/views/bucketadmincontent.handlebars +15 -15
package/lib/s3.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { S3Client, ListObjectsV2Command, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, HeadObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, ListMultipartUploadsCommand } from '@aws-sdk/client-s3';
|
|
2
2
|
import dotenv from "dotenv";
|
|
3
3
|
import { mbkautheVar } from "mbkauthe";
|
|
4
|
+
import { createLogger } from "./debug.js";
|
|
4
5
|
|
|
5
6
|
dotenv.config();
|
|
7
|
+
const debugS3 = createLogger('s3');
|
|
6
8
|
|
|
7
9
|
function parseJsonEnv(envVar, fallback = {}) {
|
|
8
10
|
if (!envVar) return fallback;
|
|
@@ -18,9 +20,15 @@ function parseJsonEnv(envVar, fallback = {}) {
|
|
|
18
20
|
|
|
19
21
|
// Parse BucketConnection JSON that contains all bucket configurations
|
|
20
22
|
const allBucketConfigs = parseJsonEnv(process.env.BucketConnection, {});
|
|
23
|
+
const availableBucketNames = Object.freeze(Object.keys(allBucketConfigs || {}));
|
|
24
|
+
const hasBucketConfigs = availableBucketNames.length > 0;
|
|
25
|
+
|
|
26
|
+
function escapeRegExp(value) {
|
|
27
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
28
|
+
}
|
|
21
29
|
|
|
22
30
|
export function getAvailableBucketNames() {
|
|
23
|
-
return
|
|
31
|
+
return availableBucketNames;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
// Helper to get default bucket name
|
|
@@ -31,9 +39,8 @@ function getDefaultBucketName() {
|
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
// Second priority: use first bucket in BucketConnection
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return buckets[0];
|
|
42
|
+
if (availableBucketNames.length > 0) {
|
|
43
|
+
return availableBucketNames[0];
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
// Fallback: return null (will cause error in getBucketConfig)
|
|
@@ -49,12 +56,12 @@ export function resolveBucketName(bucketName) {
|
|
|
49
56
|
throw new Error('No bucket selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.');
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
if (!
|
|
59
|
+
if (!hasBucketConfigs) {
|
|
53
60
|
throw new Error('BucketConnection environment variable is not set or empty. Please configure it with your bucket connections.');
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
if (!allBucketConfigs[candidate]) {
|
|
57
|
-
throw new Error(`Bucket '${candidate}' not found in BucketConnection. Available buckets: ${
|
|
64
|
+
throw new Error(`Bucket '${candidate}' not found in BucketConnection. Available buckets: ${availableBucketNames.join(', ')}`);
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
return candidate;
|
|
@@ -96,7 +103,7 @@ export function ensureKeyHasAppPrefix(key = '') {
|
|
|
96
103
|
if (!app) throw new Error('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
|
|
97
104
|
|
|
98
105
|
// Collapse repeated app prefixes (app/app/... -> app/...)
|
|
99
|
-
const appPrefixRegex = new RegExp(`^(?:${app}\/)+`);
|
|
106
|
+
const appPrefixRegex = new RegExp(`^(?:${escapeRegExp(app)}\/)+`);
|
|
100
107
|
if (!cleaned.startsWith(`${app}/`)) {
|
|
101
108
|
cleaned = `${app}/${cleaned}`;
|
|
102
109
|
} else {
|
|
@@ -137,7 +144,7 @@ export function ensurePrefix(prefix = '') {
|
|
|
137
144
|
|
|
138
145
|
if (p.startsWith(`${app}/`)) {
|
|
139
146
|
// collapse repeated app/ sequences
|
|
140
|
-
const appPrefixRegex = new RegExp(`^(?:${app}\/)+`);
|
|
147
|
+
const appPrefixRegex = new RegExp(`^(?:${escapeRegExp(app)}\/)+`);
|
|
141
148
|
return p.replace(appPrefixRegex, `${app}/`);
|
|
142
149
|
}
|
|
143
150
|
return `${app}/${p}`;
|
|
@@ -157,7 +164,7 @@ export function getBucketConfig(bucketName) {
|
|
|
157
164
|
if (_configCache.has(cacheKey)) return _configCache.get(cacheKey);
|
|
158
165
|
|
|
159
166
|
// Look up bucket config from BucketConnection JSON
|
|
160
|
-
if (!
|
|
167
|
+
if (!hasBucketConfigs) {
|
|
161
168
|
console.error(`[mbkbucket] ❌ BucketConnection is not configured or failed to parse`);
|
|
162
169
|
console.error(`[mbkbucket] Please set BucketConnection in your .env file with this format:`);
|
|
163
170
|
console.error(`[mbkbucket] BucketConnection={"r2":{"BUCKET_NAME":"...","ACCESS_KEY_ID":"...","SECRET_ACCESS_KEY":"...","ENDPOINT":"https://..."}}`);
|
|
@@ -170,7 +177,7 @@ export function getBucketConfig(bucketName) {
|
|
|
170
177
|
const cfg = allBucketConfigs[bucketName];
|
|
171
178
|
|
|
172
179
|
if (!cfg) {
|
|
173
|
-
throw new Error(`Bucket '${bucketName}' not found in BucketConnection. Available buckets: ${
|
|
180
|
+
throw new Error(`Bucket '${bucketName}' not found in BucketConnection. Available buckets: ${availableBucketNames.join(', ')}`);
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
_configCache.set(cacheKey, cfg);
|
|
@@ -250,10 +257,10 @@ async function checkHealth() {
|
|
|
250
257
|
// Quick diagnostics at startup
|
|
251
258
|
(async () => {
|
|
252
259
|
try {
|
|
253
|
-
|
|
260
|
+
debugS3('mbkautheVar snapshot: %O', { APP_NAME: mbkautheVar?.APP_NAME, bucket: mbkautheVar?.bucket });
|
|
254
261
|
const health = await checkHealth();
|
|
255
262
|
if (health.status === 'healthy') {
|
|
256
|
-
|
|
263
|
+
debugS3('Connected to bucket: %s (%sms)', health.bucket, health.responseTime);
|
|
257
264
|
} else {
|
|
258
265
|
console.error(`[mbkbucket] S3 connection failed:`, health.error);
|
|
259
266
|
}
|
|
@@ -286,11 +293,12 @@ export async function uploadFile(key, fileBuffer, contentType, options = {}) {
|
|
|
286
293
|
const config = getBucketConfig(bucketName);
|
|
287
294
|
const bucket = config.BUCKET_NAME;
|
|
288
295
|
|
|
289
|
-
|
|
296
|
+
debugS3('Uploading file to bucket=%s key=%s', bucket, keyToUpload);
|
|
290
297
|
|
|
291
298
|
// Add default metadata (supports metadata)
|
|
299
|
+
const uploadedAt = new Date().toISOString();
|
|
292
300
|
const defaultMetadata = {
|
|
293
|
-
'uploaded-at':
|
|
301
|
+
'uploaded-at': uploadedAt,
|
|
294
302
|
'file-size': fileBuffer.length.toString(),
|
|
295
303
|
'upload-source': 'web-portal',
|
|
296
304
|
...metadata
|
|
@@ -321,7 +329,7 @@ export async function uploadFile(key, fileBuffer, contentType, options = {}) {
|
|
|
321
329
|
fileSize: fileBuffer.length,
|
|
322
330
|
key: keyToUpload,
|
|
323
331
|
contentType,
|
|
324
|
-
uploadedAt
|
|
332
|
+
uploadedAt
|
|
325
333
|
};
|
|
326
334
|
} catch (error) {
|
|
327
335
|
console.error(`Upload failed for key ${key}:`, error);
|
|
@@ -361,7 +369,7 @@ export async function downloadFile(key, options = {}) {
|
|
|
361
369
|
|
|
362
370
|
// Log range requests for debugging
|
|
363
371
|
if (range) {
|
|
364
|
-
|
|
372
|
+
debugS3('Range request for %s: %s', keyToDownload, range);
|
|
365
373
|
}
|
|
366
374
|
|
|
367
375
|
const command = new GetObjectCommand({
|
|
@@ -374,9 +382,7 @@ export async function downloadFile(key, options = {}) {
|
|
|
374
382
|
...(responseContentType && { ResponseContentType: responseContentType }),
|
|
375
383
|
});
|
|
376
384
|
|
|
377
|
-
const startTime = Date.now();
|
|
378
385
|
const result = await client.send(command);
|
|
379
|
-
const downloadTime = Date.now() - startTime;
|
|
380
386
|
|
|
381
387
|
return {
|
|
382
388
|
...result,
|
|
@@ -446,6 +452,8 @@ export async function deleteFiles(keys, bucketName) {
|
|
|
446
452
|
// S3 allows max 1000 objects per delete request
|
|
447
453
|
const maxBatchSize = 1000;
|
|
448
454
|
const results = [];
|
|
455
|
+
let deletedCount = 0;
|
|
456
|
+
const errors = [];
|
|
449
457
|
|
|
450
458
|
for (let i = 0; i < keys.length; i += maxBatchSize) {
|
|
451
459
|
const batch = keys.slice(i, i + maxBatchSize).map(k => ({ Key: ensureKeyHasAppPrefix(k) }));
|
|
@@ -460,12 +468,14 @@ export async function deleteFiles(keys, bucketName) {
|
|
|
460
468
|
|
|
461
469
|
const result = await client.send(command);
|
|
462
470
|
results.push(result);
|
|
471
|
+
deletedCount += result.Deleted?.length || 0;
|
|
472
|
+
if (result.Errors?.length) errors.push(...result.Errors);
|
|
463
473
|
}
|
|
464
474
|
|
|
465
475
|
return {
|
|
466
476
|
results,
|
|
467
|
-
deletedCount
|
|
468
|
-
errors
|
|
477
|
+
deletedCount,
|
|
478
|
+
errors,
|
|
469
479
|
deletedAt: new Date().toISOString()
|
|
470
480
|
};
|
|
471
481
|
} catch (error) {
|
|
@@ -600,19 +610,21 @@ export async function getFileMetadata(key, bucketName) {
|
|
|
600
610
|
});
|
|
601
611
|
|
|
602
612
|
const result = await client.send(command);
|
|
613
|
+
const queriedAt = new Date().toISOString();
|
|
603
614
|
|
|
604
615
|
return {
|
|
605
616
|
...result,
|
|
606
617
|
key: keyToCheck,
|
|
607
618
|
exists: true,
|
|
608
|
-
queriedAt
|
|
619
|
+
queriedAt
|
|
609
620
|
};
|
|
610
621
|
} catch (error) {
|
|
622
|
+
const queriedAt = new Date().toISOString();
|
|
611
623
|
if (error.name === 'NotFound' || error.name === 'NoSuchKey') {
|
|
612
624
|
return {
|
|
613
625
|
key,
|
|
614
626
|
exists: false,
|
|
615
|
-
queriedAt
|
|
627
|
+
queriedAt
|
|
616
628
|
};
|
|
617
629
|
}
|
|
618
630
|
|
|
@@ -655,13 +667,14 @@ export async function createMultipartUpload(key, contentType = 'application/octe
|
|
|
655
667
|
const keyToUse = ensureKeyHasAppPrefix(key);
|
|
656
668
|
const cfg = getBucketConfig(bucketName);
|
|
657
669
|
const client = getBucketClient(bucketName);
|
|
670
|
+
const uploadedAt = new Date().toISOString();
|
|
658
671
|
const command = new CreateMultipartUploadCommand({
|
|
659
672
|
Bucket: cfg.BUCKET_NAME,
|
|
660
673
|
Key: keyToUse,
|
|
661
674
|
ContentType: contentType,
|
|
662
675
|
Metadata: {
|
|
663
676
|
'upload-source': 'web-portal',
|
|
664
|
-
'uploaded-at':
|
|
677
|
+
'uploaded-at': uploadedAt,
|
|
665
678
|
...metadata
|
|
666
679
|
}
|
|
667
680
|
});
|
|
@@ -803,7 +816,7 @@ export async function cleanupIncompleteMultipartUploads(olderThanDays = 7, prefi
|
|
|
803
816
|
const uploads = await listIncompleteMultipartUploads(prefix, bucketName);
|
|
804
817
|
|
|
805
818
|
if (!uploads.length) {
|
|
806
|
-
|
|
819
|
+
debugS3('No incomplete multipart uploads found');
|
|
807
820
|
return { abortedCount: 0, uploads: [] };
|
|
808
821
|
}
|
|
809
822
|
|
|
@@ -815,22 +828,22 @@ export async function cleanupIncompleteMultipartUploads(olderThanDays = 7, prefi
|
|
|
815
828
|
});
|
|
816
829
|
|
|
817
830
|
if (!uploadsToAbort.length) {
|
|
818
|
-
|
|
831
|
+
debugS3('No incomplete uploads older than %s days', olderThanDays);
|
|
819
832
|
return { abortedCount: 0, uploads: [] };
|
|
820
833
|
}
|
|
821
834
|
|
|
822
|
-
|
|
835
|
+
debugS3('Found %s incomplete uploads to clean up', uploadsToAbort.length);
|
|
823
836
|
|
|
824
837
|
const aborted = [];
|
|
825
838
|
for (const upload of uploadsToAbort) {
|
|
826
839
|
try {
|
|
827
|
-
await abortMultipartUpload(upload.Key, upload.UploadId);
|
|
840
|
+
await abortMultipartUpload(upload.Key, upload.UploadId, bucketName);
|
|
828
841
|
aborted.push({
|
|
829
842
|
key: upload.Key,
|
|
830
843
|
uploadId: upload.UploadId,
|
|
831
844
|
initiated: upload.Initiated
|
|
832
845
|
});
|
|
833
|
-
|
|
846
|
+
debugS3('Aborted incomplete upload: %s (initiated: %s)', upload.Key, upload.Initiated);
|
|
834
847
|
} catch (err) {
|
|
835
848
|
console.error(`[mbkbucket] Failed to abort upload ${upload.Key}:`, err);
|
|
836
849
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mbkbucket",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Flexible S3/R2 bucket management library and Express router for mbktech.org applications, providing upload, download, listing, and multipart utilities with built-in configuration validation and health checks",
|
|
5
5
|
"author": "Muhammad Bin Khalid",
|
|
6
6
|
"homepage": "https://github.com/MIbnEKhalid/mbkbucket#readme",
|
|
@@ -9,18 +9,19 @@
|
|
|
9
9
|
"type": "module",
|
|
10
10
|
"types": "index.d.ts",
|
|
11
11
|
"scripts": {
|
|
12
|
-
"dev": "cross-env
|
|
12
|
+
"dev": "cross-env NODE_ENV=dev nodemon index.js",
|
|
13
13
|
"test": "node --test"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@aws-sdk/client-s3": "^3.968.0",
|
|
17
17
|
"@aws-sdk/s3-request-presigner": "^3.1000.0",
|
|
18
18
|
"compression": "^1.8.0",
|
|
19
|
+
"debug": "^2.6.9",
|
|
19
20
|
"dotenv": "^16.6.1",
|
|
20
21
|
"express": "^4.22.1",
|
|
21
22
|
"express-handlebars": "^8.0.3",
|
|
22
23
|
"express-rate-limit": "^7.0.1",
|
|
23
|
-
"mbkauthe": "
|
|
24
|
+
"mbkauthe": "5.0.4",
|
|
24
25
|
"multer": "^2.0.2",
|
|
25
26
|
"pg": "^8.16.3"
|
|
26
27
|
},
|
|
@@ -49,4 +50,4 @@
|
|
|
49
50
|
"mbkauthe",
|
|
50
51
|
"mbktech.org"
|
|
51
52
|
]
|
|
52
|
-
}
|
|
53
|
+
}
|
package/public/bucketadmin.css
CHANGED
|
@@ -731,9 +731,18 @@
|
|
|
731
731
|
align-items: center;
|
|
732
732
|
gap: 0.5rem;
|
|
733
733
|
font-size: 0.9rem;
|
|
734
|
+
min-width: 0;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
.breadcrumb-nav .breadcrumb-item::before {
|
|
738
|
+
display: none !important;
|
|
739
|
+
content: none !important;
|
|
734
740
|
}
|
|
735
741
|
|
|
736
742
|
.breadcrumb-link {
|
|
743
|
+
background: transparent;
|
|
744
|
+
border: 0;
|
|
745
|
+
padding: 0;
|
|
737
746
|
color: var(--primary-color-ml);
|
|
738
747
|
cursor: pointer;
|
|
739
748
|
transition: all 0.2s ease;
|
|
@@ -754,6 +763,8 @@
|
|
|
754
763
|
.breadcrumb-current {
|
|
755
764
|
color: var(--dark-color-ml);
|
|
756
765
|
font-weight: 600;
|
|
766
|
+
min-width: 0;
|
|
767
|
+
overflow-wrap: anywhere;
|
|
757
768
|
}
|
|
758
769
|
|
|
759
770
|
.file-icon {
|
|
@@ -2048,6 +2059,19 @@
|
|
|
2048
2059
|
.breadcrumb-nav {
|
|
2049
2060
|
padding: 0.5rem 1rem;
|
|
2050
2061
|
font-size: 0.85rem;
|
|
2062
|
+
gap: 0.35rem;
|
|
2063
|
+
overflow: visible;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
.breadcrumb-item {
|
|
2067
|
+
max-width: 100%;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
.breadcrumb-current,
|
|
2071
|
+
.breadcrumb-link {
|
|
2072
|
+
max-width: calc(100vw - 4rem);
|
|
2073
|
+
white-space: normal;
|
|
2074
|
+
overflow-wrap: anywhere;
|
|
2051
2075
|
}
|
|
2052
2076
|
|
|
2053
2077
|
.file-size {
|
|
@@ -2172,4 +2196,103 @@
|
|
|
2172
2196
|
.dtm {
|
|
2173
2197
|
display: block;
|
|
2174
2198
|
}
|
|
2175
|
-
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
.table-cell-center {
|
|
2202
|
+
text-align: center;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
.table-select-col {
|
|
2206
|
+
width: 48px;
|
|
2207
|
+
text-align: center;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
.file-name-cell,
|
|
2211
|
+
.file-name-heading {
|
|
2212
|
+
padding-left: 1.5rem;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
.file-row-main {
|
|
2216
|
+
display: flex;
|
|
2217
|
+
align-items: center;
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
.folder-name {
|
|
2221
|
+
font-weight: 700;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
.folder-leading-icon {
|
|
2225
|
+
margin-right: 0.5rem;
|
|
2226
|
+
color: #f59e0b;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
.muted-cell-value {
|
|
2230
|
+
color: var(--gray-color-ml);
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
.path-display-icon {
|
|
2234
|
+
margin-right: 4px;
|
|
2235
|
+
opacity: 0.5;
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
.copy-btn {
|
|
2239
|
+
color: #fff;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
.view-btn.is-disabled {
|
|
2243
|
+
opacity: 0.5;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
.sortable-heading {
|
|
2247
|
+
cursor: pointer;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
.bulk-actions-controls {
|
|
2251
|
+
float: right;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
.toast-icon {
|
|
2255
|
+
font-size: 1.25rem;
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
.toast-message {
|
|
2259
|
+
flex: 1;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
.empty-state-error-icon {
|
|
2263
|
+
color: var(--danger-color-ml);
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
.empty-state-folder-icon {
|
|
2267
|
+
font-size: 4rem;
|
|
2268
|
+
color: #cbd5e0;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
.upload-progress-cell {
|
|
2272
|
+
min-width: 220px;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
.upload-progress-track {
|
|
2276
|
+
height: 8px;
|
|
2277
|
+
margin-bottom: 6px;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
.upload-progress-bar {
|
|
2281
|
+
height: 8px;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
.progress-bar-empty {
|
|
2285
|
+
width: 0%;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
.is-hidden {
|
|
2289
|
+
display: none !important;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
.bucket-switcher-status:not(.is-hidden) {
|
|
2293
|
+
display: inline;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
.file-list-card-body {
|
|
2297
|
+
padding: 0;
|
|
2298
|
+
}
|