mbkbucket 3.3.1 → 3.3.2

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 (34) hide show
  1. package/index.js +2 -12
  2. package/lib/cli.js +178 -512
  3. package/lib/public/bucketadmin.js +1 -1
  4. package/lib/src/app.js +37 -50
  5. package/lib/src/cli/auth-helper.js +24 -103
  6. package/lib/src/config/index.js +69 -94
  7. package/lib/src/config/validation.js +14 -30
  8. package/lib/src/controllers/api.controller.js +85 -176
  9. package/lib/src/controllers/dashboard.controller.js +12 -23
  10. package/lib/src/controllers/info.controller.js +17 -20
  11. package/lib/src/controllers/view.controller.js +23 -70
  12. package/lib/src/middleware/async-handler.js +1 -16
  13. package/lib/src/middleware/bucket-resolver.js +7 -13
  14. package/lib/src/middleware/error-handler.js +6 -14
  15. package/lib/src/middleware/rate-limiter.js +2 -10
  16. package/lib/src/middleware/security.js +1 -17
  17. package/lib/src/routes/api.routes.js +21 -47
  18. package/lib/src/routes/index.js +1 -9
  19. package/lib/src/routes/info.routes.js +2 -3
  20. package/lib/src/routes/view.routes.js +3 -8
  21. package/lib/src/services/s3.service.js +157 -349
  22. package/lib/src/utils/errors.js +28 -59
  23. package/lib/src/utils/helpers.js +124 -70
  24. package/package.json +6 -1
  25. /package/{lib/views → views}/bucket.handlebars +0 -0
  26. /package/{lib/views → views}/bucketadmin_alerts.handlebars +0 -0
  27. /package/{lib/views → views}/bucketadmin_delete_modal.handlebars +0 -0
  28. /package/{lib/views → views}/bucketadmin_filelist_skeleton.handlebars +0 -0
  29. /package/{lib/views → views}/bucketadmin_newfolder_modal.handlebars +0 -0
  30. /package/{lib/views → views}/bucketadmin_preview_modal.handlebars +0 -0
  31. /package/{lib/views → views}/bucketadmin_upload_modal.handlebars +0 -0
  32. /package/{lib/views → views}/bucketadmincontent.handlebars +0 -0
  33. /package/{lib/views → views}/bucketportal.handlebars +0 -0
  34. /package/{lib/views → views}/mbkbucket_info.handlebars +0 -0
@@ -1,72 +1,56 @@
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 "../utils/logger.js";
4
+ import { createLogger } from "#logger";
5
+ import { nowIso, trimSlashes, trimLeadingSlashes } from "#helpers";
5
6
 
6
7
  dotenv.config();
7
8
  const debugS3 = createLogger('s3');
8
9
 
9
10
  function parseJsonEnv(envVar, fallback = {}) {
10
- if (!envVar) return fallback;
11
- try {
12
- return JSON.parse(envVar);
13
- } catch (e) {
14
- console.error(`❌ Error parsing JSON for envVar: ${envVar ? String(envVar).slice(0, 200) : 'undefined'} - ${e.message}`);
15
- console.error(`💡 Tip: Check that inner objects are NOT quoted as strings. Use {"key":{"field":"value"}} not {"key":"{"field":"value"}"}`);
16
- return fallback;
17
- }
11
+ if (!envVar) return fallback;
12
+ try {
13
+ return JSON.parse(envVar);
14
+ } catch (e) {
15
+ console.error(`❌ Error parsing JSON for envVar: ${envVar ? String(envVar).slice(0, 200) : 'undefined'} - ${e.message}`);
16
+ console.error(`💡 Tip: Check that inner objects are NOT quoted as strings. Use {"key":{"field":"value"}} not {"key":"{"field":"value"}"}`);
17
+ return fallback;
18
+ }
18
19
  }
19
20
 
20
- // Parse BucketConnection JSON that contains all bucket configurations
21
21
  const allBucketConfigs = parseJsonEnv(process.env.BucketConnection, {});
22
22
  const availableBucketNames = Object.freeze(Object.keys(allBucketConfigs || {}));
23
23
  const hasBucketConfigs = availableBucketNames.length > 0;
24
24
 
25
- function escapeRegExp(value) {
26
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
- }
25
+ const escapeRegExp = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
28
26
 
29
27
  export function getAvailableBucketNames() {
30
28
  return availableBucketNames;
31
29
  }
32
30
 
33
- // Helper to get default bucket name
34
31
  function getDefaultBucketName() {
35
- if (mbkautheVar && mbkautheVar.bucket) {
36
- return mbkautheVar.bucket;
37
- }
38
-
39
- if (availableBucketNames.length > 0) {
40
- return availableBucketNames[0];
41
- }
42
-
43
- return null;
32
+ return mbkautheVar?.bucket || (availableBucketNames.length > 0 ? availableBucketNames[0] : null);
44
33
  }
45
34
 
46
35
  export function resolveBucketName(bucketName) {
47
- const rawCandidate = (typeof bucketName === 'string') ? bucketName.trim() : bucketName;
48
- const candidate = (rawCandidate === undefined || rawCandidate === null || rawCandidate === '')
49
- ? getDefaultBucketName()
50
- : String(rawCandidate);
36
+ const raw = typeof bucketName === 'string' ? bucketName.trim() : bucketName;
37
+ const candidate = !raw ? getDefaultBucketName() : String(raw);
51
38
 
52
39
  if (!candidate) {
53
40
  throw new Error('No bucket selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.');
54
41
  }
55
-
56
42
  if (!hasBucketConfigs) {
57
43
  throw new Error('BucketConnection environment variable is not set or empty. Please configure it with your bucket connections.');
58
44
  }
59
-
60
45
  if (!allBucketConfigs[candidate]) {
61
46
  throw new Error(`Bucket '${candidate}' not found in BucketConnection. Available buckets: ${availableBucketNames.join(', ')}`);
62
47
  }
63
-
64
48
  return candidate;
65
49
  }
66
50
 
67
51
  export function getAppName() {
68
- const app = (mbkautheVar && mbkautheVar.APP_NAME);
69
- return (typeof app === 'string' && app.trim()) ? app.trim() : '';
52
+ const app = mbkautheVar?.APP_NAME;
53
+ return typeof app === 'string' && app.trim() ? app.trim() : '';
70
54
  }
71
55
 
72
56
  function isRootModeApp(appName) {
@@ -74,110 +58,73 @@ function isRootModeApp(appName) {
74
58
  return app === 'portal' || app === 'mbkbucket';
75
59
  }
76
60
 
77
- export function ensureKeyHasAppPrefix(key = '') {
78
- if (Array.isArray(key)) {
79
- key = key.join('/');
61
+ function validateKeySafety(str, isKey = false) {
62
+ if (isKey && !str) throw new Error('Key is required');
63
+ if (str && (str.includes('../') || str.includes('..\\') || /\.\.[\\/]/.test(str))) {
64
+ throw new Error(`Path traversal detected: invalid ${isKey ? 'key' : 'prefix'}`);
80
65
  }
81
- const app = getAppName();
82
- if (!key) throw new Error('Key is required');
83
-
84
- if (key.includes('../') || key.includes('..\\') || /\.\.[\\/]/.test(key)) {
85
- throw new Error('Path traversal detected: invalid key');
66
+ if (str && /[\x00-\x1f\x7f]/.test(str)) {
67
+ throw new Error(`Invalid characters in ${isKey ? 'key' : 'prefix'}`);
86
68
  }
69
+ }
87
70
 
88
- if (/[\x00-\x1f\x7f]/.test(key)) {
89
- throw new Error('Invalid characters in key');
90
- }
91
-
92
- let cleaned = String(key).replace(/^\/+/, '');
93
-
94
- if (isRootModeApp(app)) {
95
- return cleaned;
96
- }
71
+ export function ensureKeyHasAppPrefix(key = '') {
72
+ if (Array.isArray(key)) key = key.join('/');
73
+ validateKeySafety(key, true);
97
74
 
75
+ const app = getAppName();
76
+ let cleaned = trimLeadingSlashes(key);
77
+ if (isRootModeApp(app)) return cleaned;
98
78
  if (!app) throw new Error('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
99
79
 
100
80
  const appPrefixRegex = new RegExp(`^(?:${escapeRegExp(app)}\/)+`);
101
- if (!cleaned.startsWith(`${app}/`)) {
102
- cleaned = `${app}/${cleaned}`;
103
- } else {
104
- cleaned = cleaned.replace(appPrefixRegex, `${app}/`);
105
- }
106
- return cleaned;
81
+ return cleaned.startsWith(`${app}/`) ? cleaned.replace(appPrefixRegex, `${app}/`) : `${app}/${cleaned}`;
107
82
  }
108
83
 
109
84
  export function ensurePrefix(prefix = '') {
110
- if (Array.isArray(prefix)) {
111
- prefix = prefix.join('/');
112
- }
113
- const app = getAppName();
114
-
115
- if (prefix && (prefix.includes('../') || prefix.includes('..\\') || /\.\.[\\/]/.test(prefix))) {
116
- throw new Error('Path traversal detected: invalid prefix');
117
- }
118
-
119
- if (prefix && /[\x00-\x1f\x7f]/.test(prefix)) {
120
- throw new Error('Invalid characters in prefix');
121
- }
122
-
123
- let p = String(prefix || '').replace(/^\/+/, '');
124
-
125
- if (isRootModeApp(app)) {
126
- return p;
127
- }
85
+ if (Array.isArray(prefix)) prefix = prefix.join('/');
86
+ validateKeySafety(prefix, false);
128
87
 
88
+ const app = getAppName();
89
+ let p = trimLeadingSlashes(prefix);
90
+ if (isRootModeApp(app)) return p;
129
91
  if (!app) throw new Error('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
130
-
131
- if (!p) return `${app}`;
132
-
133
- if (p === app) return app;
134
- if (p === `${app}/`) return `${app}/`;
92
+ if (!p) return app;
93
+ if (p === app || p === `${app}/`) return p;
135
94
 
136
95
  if (p.startsWith(`${app}/`)) {
137
- const appPrefixRegex = new RegExp(`^(?:${escapeRegExp(app)}\/)+`);
138
- return p.replace(appPrefixRegex, `${app}/`);
96
+ return p.replace(new RegExp(`^(?:${escapeRegExp(app)}\/)+`), `${app}/`);
139
97
  }
140
98
  return `${app}/${p}`;
141
99
  }
142
100
 
143
101
  // ---------------------------------------------------------------------------
144
- // Singleton S3 client cache
102
+ // S3 Client & Config Cache
145
103
  // ---------------------------------------------------------------------------
146
104
  const _clientCache = new Map();
147
105
  const _configCache = new Map();
148
106
 
149
107
  export function getBucketConfig(bucketName) {
150
- bucketName = resolveBucketName(bucketName);
151
-
152
- const cacheKey = bucketName;
153
- if (_configCache.has(cacheKey)) return _configCache.get(cacheKey);
108
+ const resolved = resolveBucketName(bucketName);
109
+ if (_configCache.has(resolved)) return _configCache.get(resolved);
154
110
 
155
111
  if (!hasBucketConfigs) {
156
- console.error(`[mbkbucket] ❌ BucketConnection is not configured or failed to parse`);
157
- console.error(`[mbkbucket] Please set BucketConnection in your .env file with this format:`);
158
- console.error(`[mbkbucket] BucketConnection={"r2":{"BUCKET_NAME":"...","ACCESS_KEY_ID":"...","SECRET_ACCESS_KEY":"...","ENDPOINT":"https://..."}}`);
159
- console.error(`[mbkbucket] ⚠️ Important: Inner objects must NOT be quoted as strings!`);
160
- console.error(`[mbkbucket] Wrong: {"r2":"{\\"BUCKET_NAME\\":\\"...\\"}"} ❌`);
161
- console.error(`[mbkbucket] Right: {"r2":{"BUCKET_NAME":"..."}} ✓`);
162
- throw new Error(`BucketConnection environment variable is not set or empty. Please configure it with your bucket connections.`);
112
+ console.error('[mbkbucket] ❌ BucketConnection is not configured or failed to parse');
113
+ throw new Error('BucketConnection environment variable is not set or empty. Please configure it with your bucket connections.');
163
114
  }
164
115
 
165
- const cfg = allBucketConfigs[bucketName];
116
+ const cfg = allBucketConfigs[resolved];
117
+ if (!cfg) throw new Error(`Bucket '${resolved}' not found in BucketConnection. Available buckets: ${availableBucketNames.join(', ')}`);
166
118
 
167
- if (!cfg) {
168
- throw new Error(`Bucket '${bucketName}' not found in BucketConnection. Available buckets: ${availableBucketNames.join(', ')}`);
169
- }
170
-
171
- _configCache.set(cacheKey, cfg);
119
+ _configCache.set(resolved, cfg);
172
120
  return cfg;
173
121
  }
174
122
 
175
123
  export function getBucketClient(bucketName) {
176
- bucketName = resolveBucketName(bucketName);
177
-
178
- if (_clientCache.has(bucketName)) return _clientCache.get(bucketName);
124
+ const resolved = resolveBucketName(bucketName);
125
+ if (_clientCache.has(resolved)) return _clientCache.get(resolved);
179
126
 
180
- const config = getBucketConfig(bucketName);
127
+ const config = getBucketConfig(resolved);
181
128
  const client = new S3Client({
182
129
  region: config.region || 'auto',
183
130
  endpoint: config.ENDPOINT,
@@ -199,14 +146,10 @@ export function getBucketClient(bucketName) {
199
146
  forcePathStyle: true,
200
147
  });
201
148
 
202
- _clientCache.set(bucketName, client);
149
+ _clientCache.set(resolved, client);
203
150
  return client;
204
151
  }
205
152
 
206
- /**
207
- * Resolve both client and config in a single call — avoids redundant
208
- * resolveBucketName + cache lookups in every S3 operation.
209
- */
210
153
  export function getBucketClientAndConfig(bucketName) {
211
154
  const resolved = resolveBucketName(bucketName);
212
155
  return {
@@ -216,64 +159,62 @@ export function getBucketClientAndConfig(bucketName) {
216
159
  };
217
160
  }
218
161
 
219
- // Default client singleton (lazily created on first access, not at import time)
220
162
  let _defaultClient = null;
221
163
  export const bucketClient = new Proxy({}, {
222
164
  get(_, prop) {
223
- if (!_defaultClient) _defaultClient = getBucketClient();
165
+ _defaultClient ??= getBucketClient();
224
166
  const val = _defaultClient[prop];
225
167
  return typeof val === 'function' ? val.bind(_defaultClient) : val;
226
168
  }
227
169
  });
228
170
 
229
- // Health check for connection (lazy — only runs when called explicitly)
171
+ // ---------------------------------------------------------------------------
172
+ // Health Checks
173
+ // ---------------------------------------------------------------------------
174
+
230
175
  export async function checkHealth() {
231
176
  try {
232
177
  const startTime = Date.now();
233
178
  const { client, config } = getBucketClientAndConfig();
234
- const bucketName = config.BUCKET_NAME;
179
+ const { BUCKET_NAME: bucket, region = 'auto' } = config;
235
180
 
236
- await client.send(new ListObjectsV2Command({ Bucket: bucketName, MaxKeys: 1 }));
181
+ await client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 1 }));
237
182
  return {
238
183
  status: 'healthy',
239
184
  responseTime: Date.now() - startTime,
240
- bucket: bucketName,
241
- region: config.region || 'auto',
242
- checkedAt: new Date().toISOString()
185
+ bucket,
186
+ region,
187
+ checkedAt: nowIso()
243
188
  };
244
189
  } catch (error) {
245
190
  let bucket = 'unknown';
246
191
  try { bucket = getBucketConfig().BUCKET_NAME; } catch {}
247
- return { status: 'unhealthy', error: error.message, bucket, checkedAt: new Date().toISOString() };
192
+ return { status: 'unhealthy', error: error.message, bucket, checkedAt: nowIso() };
248
193
  }
249
194
  }
250
195
 
251
- /**
252
- * Run S3 health check and log results. Should be called explicitly at app startup.
253
- */
254
196
  export async function runHealthCheck() {
255
197
  try {
256
- debugS3('mbkautheVar snapshot: %O', { APP_NAME: mbkautheVar?.APP_NAME, bucket: mbkautheVar?.bucket });
257
198
  const health = await checkHealth();
258
199
  if (health.status === 'healthy') {
259
200
  debugS3('Connected to bucket: %s (%sms)', health.bucket, health.responseTime);
260
201
  } else {
261
- console.error(`[mbkbucket] S3 connection failed:`, health.error);
202
+ console.error('[mbkbucket] S3 connection failed:', health.error);
262
203
  }
263
204
  } catch (err) {
264
205
  console.error("[mbkbucket] S3 connection test error:", err.message);
265
206
  }
266
207
  }
267
208
 
268
- // Upload file with enhanced features
209
+ // ---------------------------------------------------------------------------
210
+ // Core S3 Operations
211
+ // ---------------------------------------------------------------------------
212
+
269
213
  export async function uploadFile(key, fileBuffer, contentType, options = {}) {
270
214
  try {
271
- if (!key || !fileBuffer) {
272
- throw new Error('Key and file buffer are required');
273
- }
215
+ if (!key || !fileBuffer) throw new Error('Key and file buffer are required');
274
216
 
275
217
  const keyToUpload = ensureKeyHasAppPrefix(key);
276
-
277
218
  const {
278
219
  metadata = {},
279
220
  cacheControl = 'public, max-age=31536000',
@@ -284,37 +225,27 @@ export async function uploadFile(key, fileBuffer, contentType, options = {}) {
284
225
  } = options;
285
226
 
286
227
  const { client, config } = getBucketClientAndConfig(bucketName);
287
- const bucket = config.BUCKET_NAME;
288
-
289
- debugS3('Uploading file to bucket=%s key=%s', bucket, keyToUpload);
290
-
291
- const uploadedAt = new Date().toISOString();
292
- const defaultMetadata = {
293
- 'uploaded-at': uploadedAt,
294
- 'file-size': fileBuffer.length.toString(),
295
- 'upload-source': 'web-portal',
296
- ...metadata
297
- };
228
+ debugS3('Uploading file to bucket=%s key=%s', config.BUCKET_NAME, keyToUpload);
298
229
 
230
+ const uploadedAt = nowIso();
299
231
  const commandParams = {
300
- Bucket: bucket,
232
+ Bucket: config.BUCKET_NAME,
301
233
  Key: keyToUpload,
302
234
  Body: fileBuffer,
303
235
  ContentType: contentType,
304
236
  CacheControl: cacheControl,
305
- Metadata: defaultMetadata,
237
+ Metadata: {
238
+ 'uploaded-at': uploadedAt,
239
+ 'file-size': fileBuffer.length.toString(),
240
+ 'upload-source': 'web-portal',
241
+ ...metadata
242
+ },
306
243
  ServerSideEncryption: serverSideEncryption,
307
244
  StorageClass: storageClass,
245
+ ...(preventOverwrite && { IfNoneMatch: '*' })
308
246
  };
309
247
 
310
- if (preventOverwrite) {
311
- commandParams.IfNoneMatch = '*';
312
- }
313
-
314
- const command = new PutObjectCommand(commandParams);
315
-
316
- const result = await client.send(command);
317
-
248
+ const result = await client.send(new PutObjectCommand(commandParams));
318
249
  return {
319
250
  ...result,
320
251
  fileSize: fileBuffer.length,
@@ -324,114 +255,72 @@ export async function uploadFile(key, fileBuffer, contentType, options = {}) {
324
255
  };
325
256
  } catch (error) {
326
257
  console.error(`Upload failed for key ${key}:`, error);
327
-
328
258
  if (error.name === 'PreconditionFailed' || error.$metadata?.httpStatusCode === 412) {
329
259
  throw new Error('File already exists');
330
260
  }
331
-
332
261
  throw new Error(`Upload failed: ${error.message}`);
333
262
  }
334
263
  }
335
264
 
336
- // Download file with enhanced features
337
265
  export async function downloadFile(key, options = {}) {
338
266
  let keyToDownload;
339
267
  try {
340
- if (!key) {
341
- throw new Error('Key is required');
342
- }
343
-
268
+ if (!key) throw new Error('Key is required');
344
269
  keyToDownload = ensureKeyHasAppPrefix(key);
345
270
 
346
- const {
347
- range = null,
348
- ifNoneMatch = null,
349
- ifModifiedSince = null,
350
- responseCacheControl = null,
351
- responseContentType = null,
352
- bucketName
353
- } = options;
354
-
271
+ const { range, ifNoneMatch, ifModifiedSince, responseCacheControl, responseContentType, bucketName } = options;
355
272
  const { client, config } = getBucketClientAndConfig(bucketName);
356
- const bucket = config.BUCKET_NAME;
357
273
 
358
- if (range) {
359
- debugS3('Range request for %s: %s', keyToDownload, range);
360
- }
274
+ if (range) debugS3('Range request for %s: %s', keyToDownload, range);
361
275
 
362
- const command = new GetObjectCommand({
363
- Bucket: bucket,
276
+ const result = await client.send(new GetObjectCommand({
277
+ Bucket: config.BUCKET_NAME,
364
278
  Key: keyToDownload,
365
279
  ...(range && { Range: range }),
366
280
  ...(ifNoneMatch && { IfNoneMatch: ifNoneMatch }),
367
281
  ...(ifModifiedSince && { IfModifiedSince: ifModifiedSince }),
368
282
  ...(responseCacheControl && { ResponseCacheControl: responseCacheControl }),
369
283
  ...(responseContentType && { ResponseContentType: responseContentType }),
370
- });
371
-
372
- const result = await client.send(command);
284
+ }));
373
285
 
374
- return {
375
- ...result,
376
- key: keyToDownload,
377
- };
286
+ return { ...result, key: keyToDownload };
378
287
  } catch (error) {
379
- if (error && (error.$metadata?.httpStatusCode === 304 || error.name === '304' || error.name === 'NotModified')) {
288
+ if (error?.$metadata?.httpStatusCode === 304 || error?.name === '304' || error?.name === 'NotModified') {
380
289
  debugS3('File not modified (304) for key %s', keyToDownload);
381
290
  return { notModified: true, key: keyToDownload };
382
291
  }
383
-
384
292
  console.error(`Download failed for key ${key}:`, error);
385
-
386
- if (error.name === 'NoSuchKey') {
387
- throw new Error(`File not found: ${key}`);
388
- } else if (error.name === 'AccessDenied') {
389
- throw new Error(`Access denied for file: ${key}`);
390
- }
391
-
293
+ if (error.name === 'NoSuchKey') throw new Error(`File not found: ${key}`);
294
+ if (error.name === 'AccessDenied') throw new Error(`Access denied for file: ${key}`);
392
295
  throw new Error(`Download failed: ${error.message}`);
393
296
  }
394
297
  }
395
298
 
396
- // Delete file with enhanced error handling
397
299
  export async function deleteFile(key, bucketName) {
398
300
  try {
399
- if (!key) {
400
- throw new Error('Key is required');
401
- }
301
+ if (!key) throw new Error('Key is required');
402
302
  const keyToDelete = ensureKeyHasAppPrefix(key);
403
-
404
303
  const { client, config } = getBucketClientAndConfig(bucketName);
405
- const bucket = config.BUCKET_NAME;
406
304
 
407
- const command = new DeleteObjectCommand({
408
- Bucket: bucket,
305
+ const result = await client.send(new DeleteObjectCommand({
306
+ Bucket: config.BUCKET_NAME,
409
307
  Key: keyToDelete,
410
- });
411
-
412
- const result = await client.send(command);
308
+ }));
413
309
 
414
- return {
415
- ...result,
416
- key: keyToDelete,
417
- deletedAt: new Date().toISOString()
418
- };
310
+ return { ...result, key: keyToDelete, deletedAt: nowIso() };
419
311
  } catch (error) {
420
- console.error(`Delete failed:`, error);
312
+ console.error('Delete failed:', error);
421
313
  throw new Error(`Delete failed: ${error.message}`);
422
314
  }
423
315
  }
424
316
 
425
- // Batch delete multiple files
426
317
  export async function deleteFiles(keys, bucketName) {
427
318
  try {
428
- if (!keys || !Array.isArray(keys) || keys.length === 0) {
319
+ if (!keys || !Array.isArray(keys) || !keys.length) {
429
320
  throw new Error('Keys array is required and must not be empty');
430
321
  }
431
322
 
432
323
  const { client, config } = getBucketClientAndConfig(bucketName);
433
- const bucket = config.BUCKET_NAME;
434
-
435
324
  const maxBatchSize = 1000;
436
325
  const results = [];
437
326
  let deletedCount = 0;
@@ -439,88 +328,60 @@ export async function deleteFiles(keys, bucketName) {
439
328
 
440
329
  for (let i = 0; i < keys.length; i += maxBatchSize) {
441
330
  const batch = keys.slice(i, i + maxBatchSize).map(k => ({ Key: ensureKeyHasAppPrefix(k) }));
331
+ const result = await client.send(new DeleteObjectsCommand({
332
+ Bucket: config.BUCKET_NAME,
333
+ Delete: { Objects: batch, Quiet: false }
334
+ }));
442
335
 
443
- const command = new DeleteObjectsCommand({
444
- Bucket: bucket,
445
- Delete: {
446
- Objects: batch,
447
- Quiet: false
448
- }
449
- });
450
-
451
- const result = await client.send(command);
452
336
  results.push(result);
453
337
  deletedCount += result.Deleted?.length || 0;
454
338
  if (result.Errors?.length) errors.push(...result.Errors);
455
339
  }
456
340
 
457
- return {
458
- results,
459
- deletedCount,
460
- errors,
461
- deletedAt: new Date().toISOString()
462
- };
341
+ return { results, deletedCount, errors, deletedAt: nowIso() };
463
342
  } catch (error) {
464
343
  console.error('Batch delete failed:', error);
465
344
  throw new Error(`Batch delete failed: ${error.message}`);
466
345
  }
467
346
  }
468
347
 
469
- // Delete all objects under a folder prefix (recursive)
470
348
  export async function deleteFolder(prefix, bucketName) {
471
349
  try {
472
- if (!prefix) {
473
- throw new Error('Prefix is required');
474
- }
475
-
476
- const cleaned = String(prefix).replace(/^\/+/, '').replace(/\/+$/g, '');
477
- const effectivePrefix = ensurePrefix(cleaned) + '/';
478
-
350
+ if (!prefix) throw new Error('Prefix is required');
351
+ const effectivePrefix = `${ensurePrefix(trimSlashes(prefix))}/`;
479
352
  const { client, config } = getBucketClientAndConfig(bucketName);
480
- const bucket = config.BUCKET_NAME;
481
353
 
482
354
  const keysToDelete = [];
483
355
  let continuationToken = null;
484
356
 
485
357
  do {
486
- const listCmd = new ListObjectsV2Command({
487
- Bucket: bucket,
358
+ const resp = await client.send(new ListObjectsV2Command({
359
+ Bucket: config.BUCKET_NAME,
488
360
  Prefix: effectivePrefix,
489
361
  ContinuationToken: continuationToken,
490
362
  MaxKeys: 1000
491
- });
492
- const resp = await client.send(listCmd);
493
- const contents = resp.Contents || [];
494
- for (const obj of contents) {
495
- if (obj.Key) keysToDelete.push(obj.Key);
496
- }
363
+ }));
364
+ (resp.Contents || []).forEach(obj => { if (obj.Key) keysToDelete.push(obj.Key); });
497
365
  continuationToken = resp.IsTruncated ? resp.NextContinuationToken : null;
498
366
  } while (continuationToken);
499
367
 
500
368
  if (!keysToDelete.length) {
501
- const folderMarkerKey = effectivePrefix;
502
- const exists = await fileExists(folderMarkerKey, bucketName);
369
+ const exists = await fileExists(effectivePrefix, bucketName);
503
370
  if (exists) {
504
- await deleteFile(folderMarkerKey, bucketName);
505
- return { deletedCount: 1, deletedAt: new Date().toISOString(), prefix: effectivePrefix };
371
+ await deleteFile(effectivePrefix, bucketName);
372
+ return { deletedCount: 1, deletedAt: nowIso(), prefix: effectivePrefix };
506
373
  }
507
- return { deletedCount: 0, deletedAt: new Date().toISOString(), prefix: effectivePrefix };
374
+ return { deletedCount: 0, deletedAt: nowIso(), prefix: effectivePrefix };
508
375
  }
509
376
 
510
377
  const result = await deleteFiles(keysToDelete, bucketName);
511
-
512
- return {
513
- ...result,
514
- prefix: effectivePrefix,
515
- deletedAt: new Date().toISOString()
516
- };
378
+ return { ...result, prefix: effectivePrefix, deletedAt: nowIso() };
517
379
  } catch (error) {
518
380
  console.error('Delete folder failed:', error);
519
381
  throw new Error(`Delete folder failed: ${error.message}`);
520
382
  }
521
383
  }
522
384
 
523
- // List files with enhanced pagination and filtering
524
385
  export async function listfiles(prefix = '', options = {}) {
525
386
  try {
526
387
  const {
@@ -533,25 +394,21 @@ export async function listfiles(prefix = '', options = {}) {
533
394
  } = options;
534
395
 
535
396
  const effectivePrefix = ensurePrefix(prefix);
536
-
537
397
  const { client, config } = getBucketClientAndConfig(bucketName);
538
- const bucket = config.BUCKET_NAME;
539
398
 
540
- const command = new ListObjectsV2Command({
541
- Bucket: bucket,
399
+ const result = await client.send(new ListObjectsV2Command({
400
+ Bucket: config.BUCKET_NAME,
542
401
  Prefix: effectivePrefix,
543
402
  MaxKeys: maxKeys,
544
403
  ...(continuationToken && { ContinuationToken: continuationToken }),
545
404
  ...(delimiter && { Delimiter: delimiter }),
546
405
  ...(fetchOwner && { FetchOwner: fetchOwner }),
547
406
  ...(startAfter && { StartAfter: startAfter }),
548
- });
549
-
550
- const result = await client.send(command);
407
+ }));
551
408
 
552
409
  return {
553
410
  ...result,
554
- requestedAt: new Date().toISOString(),
411
+ requestedAt: nowIso(),
555
412
  totalFiles: result.KeyCount || 0,
556
413
  hasMore: result.IsTruncated || false,
557
414
  nextToken: result.NextContinuationToken || null
@@ -562,100 +419,77 @@ export async function listfiles(prefix = '', options = {}) {
562
419
  }
563
420
  }
564
421
 
565
- // Get file metadata without downloading
566
422
  export async function getFileMetadata(key, bucketName) {
567
423
  try {
568
- if (!key) {
569
- throw new Error('Key is required');
570
- }
424
+ if (!key) throw new Error('Key is required');
571
425
  const keyToCheck = ensureKeyHasAppPrefix(key);
572
-
573
426
  const { client, config } = getBucketClientAndConfig(bucketName);
574
- const bucket = config.BUCKET_NAME;
575
427
 
576
- const command = new HeadObjectCommand({
577
- Bucket: bucket,
428
+ const result = await client.send(new HeadObjectCommand({
429
+ Bucket: config.BUCKET_NAME,
578
430
  Key: keyToCheck,
579
- });
580
-
581
- const result = await client.send(command);
582
- const queriedAt = new Date().toISOString();
431
+ }));
583
432
 
584
- return {
585
- ...result,
586
- key: keyToCheck,
587
- exists: true,
588
- queriedAt
589
- };
433
+ return { ...result, key: keyToCheck, exists: true, queriedAt: nowIso() };
590
434
  } catch (error) {
591
- const queriedAt = new Date().toISOString();
435
+ const queriedAt = nowIso();
592
436
  if (error.name === 'NotFound' || error.name === 'NoSuchKey') {
593
- return {
594
- key,
595
- exists: false,
596
- queriedAt
597
- };
437
+ return { key, exists: false, queriedAt };
598
438
  }
599
-
600
439
  console.error(`Get metadata failed for key ${key}:`, error);
601
440
  throw new Error(`Get metadata failed: ${error.message}`);
602
441
  }
603
442
  }
604
443
 
605
- // Check if file exists
606
444
  export async function fileExists(key, bucketName) {
607
445
  try {
608
- const metadata = await getFileMetadata(key, bucketName);
609
- return metadata.exists;
610
- } catch (error) {
446
+ const meta = await getFileMetadata(key, bucketName);
447
+ return !!meta.exists;
448
+ } catch {
611
449
  return false;
612
450
  }
613
451
  }
614
452
 
615
- // Get file size without downloading
616
453
  export async function getFileSize(key, bucketName) {
617
454
  try {
618
- const metadata = await getFileMetadata(key, bucketName);
619
- return metadata.exists ? metadata.ContentLength : null;
620
- } catch (error) {
455
+ const meta = await getFileMetadata(key, bucketName);
456
+ return meta.exists ? meta.ContentLength : null;
457
+ } catch {
621
458
  return null;
622
459
  }
623
460
  }
624
461
 
625
462
  // ---------------------------------------------------------------------------
626
- // S3 Multipart Upload helpers
463
+ // Multipart Upload Helpers
627
464
  // ---------------------------------------------------------------------------
628
465
 
629
466
  export async function createMultipartUpload(key, contentType = 'application/octet-stream', metadata = {}, bucketName) {
630
467
  const keyToUse = ensureKeyHasAppPrefix(key);
631
468
  const { client, config } = getBucketClientAndConfig(bucketName);
632
- const uploadedAt = new Date().toISOString();
633
- const command = new CreateMultipartUploadCommand({
469
+ const result = await client.send(new CreateMultipartUploadCommand({
634
470
  Bucket: config.BUCKET_NAME,
635
471
  Key: keyToUse,
636
472
  ContentType: contentType,
637
473
  Metadata: {
638
474
  'upload-source': 'web-portal',
639
- 'uploaded-at': uploadedAt,
475
+ 'uploaded-at': nowIso(),
640
476
  ...metadata
641
477
  }
642
- });
643
- const result = await client.send(command);
478
+ }));
644
479
  return { uploadId: result.UploadId, key: keyToUse };
645
480
  }
646
481
 
647
482
  export async function uploadPart(key, uploadId, partNumber, buffer, bucketName) {
648
483
  const keyToUse = ensureKeyHasAppPrefix(key);
649
484
  const { client, config } = getBucketClientAndConfig(bucketName);
650
- const command = new UploadPartCommand({
485
+ const result = await client.send(new UploadPartCommand({
651
486
  Bucket: config.BUCKET_NAME,
652
487
  Key: keyToUse,
653
488
  UploadId: uploadId,
654
489
  PartNumber: partNumber,
655
490
  Body: buffer,
656
491
  ContentLength: buffer.length
657
- });
658
- const result = await client.send(command);
492
+ }));
659
493
  return { ETag: result.ETag, partNumber };
660
494
  }
661
495
 
@@ -663,59 +497,52 @@ export async function completeMultipartUpload(key, uploadId, parts, bucketName)
663
497
  const keyToUse = ensureKeyHasAppPrefix(key);
664
498
  const { client, config } = getBucketClientAndConfig(bucketName);
665
499
  const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber);
666
- const command = new CompleteMultipartUploadCommand({
500
+ await client.send(new CompleteMultipartUploadCommand({
667
501
  Bucket: config.BUCKET_NAME,
668
502
  Key: keyToUse,
669
503
  UploadId: uploadId,
670
504
  MultipartUpload: {
671
505
  Parts: sorted.map(p => ({ PartNumber: p.partNumber, ETag: p.ETag }))
672
506
  }
673
- });
674
- await client.send(command);
507
+ }));
675
508
  return { key: keyToUse };
676
509
  }
677
510
 
678
511
  export async function abortMultipartUpload(key, uploadId, bucketName) {
679
512
  const keyToUse = ensureKeyHasAppPrefix(key);
680
513
  const { client, config } = getBucketClientAndConfig(bucketName);
681
- const command = new AbortMultipartUploadCommand({
514
+ await client.send(new AbortMultipartUploadCommand({
682
515
  Bucket: config.BUCKET_NAME,
683
516
  Key: keyToUse,
684
517
  UploadId: uploadId
685
- });
686
- await client.send(command);
687
- return { key: keyToUse, abortedAt: new Date().toISOString() };
518
+ }));
519
+ return { key: keyToUse, abortedAt: nowIso() };
688
520
  }
689
521
 
690
- // Generate signed URL for temporary access
691
522
  export async function generateSignedUrl(key, operation = 'getObject', expiresIn = 3600, bucketName) {
692
523
  try {
693
524
  const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
694
525
  const keyToUse = ensureKeyHasAppPrefix(key);
695
-
696
- let command;
697
526
  const { client, config } = getBucketClientAndConfig(bucketName);
698
- const resolvedBucketName = config.BUCKET_NAME;
699
- switch (operation) {
700
- case 'getObject':
701
- command = new GetObjectCommand({ Bucket: resolvedBucketName, Key: keyToUse });
702
- break;
703
- case 'putObject':
704
- command = new PutObjectCommand({ Bucket: resolvedBucketName, Key: keyToUse });
705
- break;
706
- default:
707
- throw new Error(`Unsupported operation: ${operation}`);
708
- }
527
+ const bucket = config.BUCKET_NAME;
709
528
 
710
- const signedUrl = await getSignedUrl(client, command, { expiresIn });
529
+ const command = operation === 'getObject'
530
+ ? new GetObjectCommand({ Bucket: bucket, Key: keyToUse })
531
+ : operation === 'putObject'
532
+ ? new PutObjectCommand({ Bucket: bucket, Key: keyToUse })
533
+ : null;
534
+
535
+ if (!command) throw new Error(`Unsupported operation: ${operation}`);
711
536
 
537
+ const signedUrl = await getSignedUrl(client, command, { expiresIn });
538
+ const now = Date.now();
712
539
  return {
713
540
  url: signedUrl,
714
541
  key,
715
542
  operation,
716
543
  expiresIn,
717
- expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
718
- generatedAt: new Date().toISOString()
544
+ expiresAt: new Date(now + expiresIn * 1000).toISOString(),
545
+ generatedAt: new Date(now).toISOString()
719
546
  };
720
547
  } catch (error) {
721
548
  console.error(`Generate signed URL failed for key ${key}:`, error);
@@ -723,21 +550,14 @@ export async function generateSignedUrl(key, operation = 'getObject', expiresIn
723
550
  }
724
551
  }
725
552
 
726
- // ---------------------------------------------------------------------------
727
- // Multipart Upload Cleanup
728
- // ---------------------------------------------------------------------------
729
-
730
553
  export async function listIncompleteMultipartUploads(prefix = '', bucketName) {
731
554
  try {
732
555
  const effectivePrefix = ensurePrefix(prefix || '');
733
556
  const { client, config } = getBucketClientAndConfig(bucketName);
734
-
735
- const command = new ListMultipartUploadsCommand({
557
+ const result = await client.send(new ListMultipartUploadsCommand({
736
558
  Bucket: config.BUCKET_NAME,
737
559
  Prefix: effectivePrefix
738
- });
739
-
740
- const result = await client.send(command);
560
+ }));
741
561
  return result.Uploads || [];
742
562
  } catch (error) {
743
563
  console.error('Failed to list incomplete multipart uploads:', error);
@@ -748,7 +568,6 @@ export async function listIncompleteMultipartUploads(prefix = '', bucketName) {
748
568
  export async function cleanupIncompleteMultipartUploads(olderThanDays = 7, prefix = '', bucketName) {
749
569
  try {
750
570
  const uploads = await listIncompleteMultipartUploads(prefix, bucketName);
751
-
752
571
  if (!uploads.length) {
753
572
  debugS3('No incomplete multipart uploads found');
754
573
  return { abortedCount: 0, uploads: [] };
@@ -757,37 +576,26 @@ export async function cleanupIncompleteMultipartUploads(olderThanDays = 7, prefi
757
576
  const cutoffDate = new Date();
758
577
  cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
759
578
 
760
- const uploadsToAbort = uploads.filter(upload => {
761
- return upload.Initiated && new Date(upload.Initiated) < cutoffDate;
762
- });
763
-
579
+ const uploadsToAbort = uploads.filter(u => u.Initiated && new Date(u.Initiated) < cutoffDate);
764
580
  if (!uploadsToAbort.length) {
765
581
  debugS3('No incomplete uploads older than %s days', olderThanDays);
766
582
  return { abortedCount: 0, uploads: [] };
767
583
  }
768
584
 
769
585
  debugS3('Found %s incomplete uploads to clean up', uploadsToAbort.length);
770
-
771
586
  const aborted = [];
587
+
772
588
  for (const upload of uploadsToAbort) {
773
589
  try {
774
590
  await abortMultipartUpload(upload.Key, upload.UploadId, bucketName);
775
- aborted.push({
776
- key: upload.Key,
777
- uploadId: upload.UploadId,
778
- initiated: upload.Initiated
779
- });
591
+ aborted.push({ key: upload.Key, uploadId: upload.UploadId, initiated: upload.Initiated });
780
592
  debugS3('Aborted incomplete upload: %s (initiated: %s)', upload.Key, upload.Initiated);
781
593
  } catch (err) {
782
594
  console.error(`[mbkbucket] Failed to abort upload ${upload.Key}:`, err);
783
595
  }
784
596
  }
785
597
 
786
- return {
787
- abortedCount: aborted.length,
788
- uploads: aborted,
789
- cleanedAt: new Date().toISOString()
790
- };
598
+ return { abortedCount: aborted.length, uploads: aborted, cleanedAt: nowIso() };
791
599
  } catch (error) {
792
600
  console.error('Cleanup of incomplete multipart uploads failed:', error);
793
601
  throw new Error(`Cleanup failed: ${error.message}`);