mbkbucket 3.8.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.env.example +15 -14
  2. package/README.md +561 -353
  3. package/index.js +32 -30
  4. package/lib/public/bucketadmin.css +2042 -1708
  5. package/lib/public/bucketadmin.js +1622 -1286
  6. package/lib/public/mbkbucket-helper-demo.html +4 -24
  7. package/lib/public/mbkbucket-helper.css +28 -34
  8. package/lib/public/mbkbucket-helper.js +4 -3
  9. package/lib/src/config/validation.js +86 -73
  10. package/lib/src/controllers/api.controller.js +459 -410
  11. package/lib/src/controllers/dashboard.controller.js +69 -49
  12. package/lib/src/controllers/info.controller.js +34 -3
  13. package/lib/src/controllers/view.controller.js +233 -231
  14. package/lib/src/core/storage/errors.js +62 -0
  15. package/lib/src/core/storage/gdrive-auth.js +193 -0
  16. package/lib/src/core/storage/gdrive-provider.js +656 -0
  17. package/lib/src/core/storage/index.js +14 -0
  18. package/lib/src/core/storage/models.js +80 -0
  19. package/lib/src/core/storage/path-strategy.js +65 -0
  20. package/lib/src/core/storage/s3-provider.js +491 -0
  21. package/lib/src/core/storage/storage-manager.js +203 -0
  22. package/lib/src/core/storage/storage-provider.js +217 -0
  23. package/lib/src/middleware/bucket-resolver.js +37 -37
  24. package/lib/src/routes/api.routes.js +5 -1
  25. package/lib/src/routes/index.js +60 -60
  26. package/lib/src/routes/view.routes.js +28 -25
  27. package/lib/src/services/s3.service.js +42 -594
  28. package/lib/src/services/storage.service.js +213 -0
  29. package/lib/src/utils/errors.js +56 -46
  30. package/package.json +1 -1
  31. package/views/bucket.hbs +47 -71
  32. package/views/bucketadmin_move_modal.hbs +35 -0
  33. package/views/bucketadmin_preview_modal.hbs +108 -40
  34. package/views/bucketadmincontent.hbs +193 -144
  35. package/views/mbkbucket_info.hbs +134 -232
@@ -1,410 +1,459 @@
1
- import { pipeline } from "stream/promises";
2
- import { listfiles, uploadFile, deleteFile, deleteFiles, deleteFolder, downloadFile, getFileMetadata, fileExists, ensureKeyHasAppPrefix, ensurePrefix, createMultipartUpload, uploadPart, completeMultipartUpload, abortMultipartUpload, listIncompleteMultipartUploads, cleanupIncompleteMultipartUploads } from "../services/s3.service.js";
3
- import { sendApiError, classifyApiError } from "../utils/errors.js";
4
- import { getBaseName, isSensitiveTextType, buildKey, normalizeKeyParam, getFolderPath, destroyStream } from "../utils/helpers.js";
5
- import { createLogger } from "#logger";
6
-
7
- const debugApi = createLogger('api');
8
-
9
- function resolvePrefix(req, res, prefix) {
10
- try {
11
- return ensurePrefix(prefix);
12
- } catch (e) {
13
- res.status(400).json({ success: false, error: e.message });
14
- return null;
15
- }
16
- }
17
-
18
- async function cleanupFolderMarker(folderPath, bucketName) {
19
- if (!folderPath) return;
20
- try {
21
- const meta = await getFileMetadata(folderPath, bucketName);
22
- if (meta.exists && (meta.ContentLength === 0 || meta.Metadata?.marker === 'true')) {
23
- await deleteFile(folderPath, bucketName);
24
- debugApi('Cleaned up folder marker: %s', folderPath);
25
- }
26
- } catch (err) {
27
- debugApi('Failed to clean up folder marker: %s', err.message);
28
- }
29
- }
30
-
31
- export async function cleanupUploads(req, res) {
32
- try {
33
- const { olderThanDays = 7, prefix = '' } = req.body;
34
- const days = parseInt(olderThanDays, 10);
35
- if (isNaN(days) || days < 1) {
36
- return res.status(400).json({ success: false, error: 'olderThanDays must be a positive integer' });
37
- }
38
-
39
- debugApi('Starting cleanup of incomplete uploads older than %s days', days);
40
- const result = await cleanupIncompleteMultipartUploads(days, prefix, req.activeBucket);
41
-
42
- res.json({
43
- success: true,
44
- message: `Cleaned up ${result.abortedCount} incomplete multipart upload(s)`,
45
- ...result
46
- });
47
- } catch (error) {
48
- console.error('[mbkbucket] Cleanup failed:', error);
49
- return sendApiError(res, error, 'Cleanup failed');
50
- }
51
- }
52
-
53
- export async function listIncompleteUploads(req, res) {
54
- try {
55
- const { prefix = '' } = req.query;
56
- const uploads = await listIncompleteMultipartUploads(prefix, req.activeBucket);
57
- res.json({
58
- success: true,
59
- count: uploads.length,
60
- uploads: uploads.map(u => ({
61
- key: u.Key,
62
- uploadId: u.UploadId,
63
- initiated: u.Initiated,
64
- initiator: u.Initiator?.DisplayName
65
- }))
66
- });
67
- } catch (error) {
68
- console.error('[mbkbucket] Failed to list incomplete uploads:', error);
69
- return sendApiError(res, error, 'Failed to list incomplete uploads');
70
- }
71
- }
72
-
73
- export async function listFiles(req, res) {
74
- const { prefix = '', page = '1', search = '', recursive = 'true', token = '' } = req.query;
75
- const pageSize = 100;
76
- const bucketName = req.activeBucket;
77
-
78
- const effectivePrefix = resolvePrefix(req, res, prefix);
79
- if (effectivePrefix === null) return;
80
-
81
- const isSearch = Boolean(search && search.trim());
82
- const isRecursive = recursive === 'true';
83
- const useOptimizedListing = !isSearch && !isRecursive;
84
-
85
- try {
86
- let files = [];
87
- let folders = [];
88
- let nextContinuationToken = null;
89
-
90
- if (useOptimizedListing) {
91
- let listPrefix = effectivePrefix ? String(effectivePrefix).replace(/^\/+/, '') : '';
92
- if (listPrefix && !listPrefix.endsWith('/')) listPrefix += '/';
93
-
94
- const result = await listfiles(listPrefix, {
95
- continuationToken: token || undefined,
96
- delimiter: '/',
97
- maxKeys: 1000,
98
- bucketName
99
- });
100
-
101
- files = (result.Contents || []).filter(f => !f.Key.endsWith('/'));
102
- folders = (result.CommonPrefixes || [])
103
- .filter(p => p.Prefix !== listPrefix && p.Prefix !== effectivePrefix)
104
- .map(p => p.Prefix);
105
- nextContinuationToken = result.NextContinuationToken;
106
-
107
- return res.json({
108
- success: true,
109
- files,
110
- folders,
111
- prefix: effectivePrefix,
112
- search,
113
- currentPage: 1,
114
- totalPages: 1,
115
- hasNextPage: !!nextContinuationToken,
116
- hasPrevPage: false,
117
- nextContinuationToken,
118
- mode: 'optimized',
119
- totalFiles: -1,
120
- paginationType: 'continuation-token'
121
- });
122
- }
123
-
124
- let continuationToken;
125
- do {
126
- const result = await listfiles(effectivePrefix, { continuationToken, bucketName });
127
- (result.Contents || []).forEach(file => {
128
- if (!file.Key.endsWith('/')) files.push(file);
129
- });
130
- continuationToken = result.nextToken;
131
- } while (continuationToken);
132
-
133
- if (!files.length && !search) {
134
- try {
135
- const rootResult = await listfiles('', { bucketName });
136
- if (rootResult.Contents?.length) files = rootResult.Contents;
137
- } catch (fallbackErr) {
138
- debugApi('Fallback root listing failed: %s', fallbackErr.message);
139
- }
140
- }
141
-
142
- if (isSearch) {
143
- const searchLower = search.trim().toLowerCase();
144
- files = files.filter(file => file.Key.toLowerCase().includes(searchLower));
145
- }
146
-
147
- const totalFiles = files.length;
148
- const totalPages = Math.max(1, Math.ceil(totalFiles / pageSize));
149
- const currentPage = Math.max(1, parseInt(page, 10) || 1);
150
- const paginatedFiles = files.slice((currentPage - 1) * pageSize, currentPage * pageSize);
151
-
152
- res.json({
153
- success: true,
154
- files: paginatedFiles,
155
- folders: [],
156
- prefix: effectivePrefix,
157
- search,
158
- currentPage,
159
- totalPages,
160
- hasNextPage: currentPage < totalPages,
161
- hasPrevPage: currentPage > 1,
162
- nextPage: currentPage + 1,
163
- prevPage: Math.max(1, currentPage - 1),
164
- totalFiles
165
- });
166
- } catch (error) {
167
- console.error("Error listing files:", error);
168
- const mapped = classifyApiError(error, 'Error listing files');
169
- return res.status(mapped.status).json({
170
- success: false,
171
- code: mapped.code,
172
- error: mapped.message,
173
- files: [],
174
- totalFiles: 0
175
- });
176
- }
177
- }
178
-
179
- export async function uploadSingleFile(req, res) {
180
- try {
181
- const bucketName = req.activeBucket;
182
- if (!req.file) return res.status(400).json({ success: false, error: 'No file selected' });
183
-
184
- const effectivePrefix = resolvePrefix(req, res, req.body.prefix || '');
185
- if (effectivePrefix === null) return;
186
-
187
- const key = buildKey(effectivePrefix, req.file.originalname);
188
- const uploadOptions = {
189
- metadata: {
190
- 'original-name': req.file.originalname,
191
- 'upload-source': 'web-portal',
192
- 'user-agent': req.headers['user-agent'] || 'unknown'
193
- },
194
- bucketName,
195
- preventOverwrite: true
196
- };
197
-
198
- try {
199
- await uploadFile(key, req.file.buffer, req.file.mimetype, uploadOptions);
200
- await cleanupFolderMarker(getFolderPath(key), bucketName);
201
- res.json({ success: true, message: 'File uploaded successfully', key });
202
- } catch (uploadError) {
203
- if (uploadError.message.includes('already exists')) {
204
- return res.status(409).json({ success: false, error: `File already exists: ${req.file.originalname}` });
205
- }
206
- throw uploadError;
207
- }
208
- } catch (error) {
209
- console.error("Error uploading file:", error);
210
- return sendApiError(res, error, 'Upload failed');
211
- }
212
- }
213
-
214
- export async function initiateMultipartUpload(req, res) {
215
- try {
216
- const bucketName = req.activeBucket;
217
- const { fileName, prefix = '', contentType = 'application/octet-stream' } = req.body;
218
- if (!fileName) return res.status(400).json({ success: false, error: 'fileName is required' });
219
-
220
- const effectivePrefix = resolvePrefix(req, res, prefix);
221
- if (effectivePrefix === null) return;
222
-
223
- const key = buildKey(effectivePrefix, fileName);
224
- if (await fileExists(key, bucketName)) {
225
- return res.status(409).json({ success: false, error: `File already exists: ${fileName}` });
226
- }
227
-
228
- const result = await createMultipartUpload(key, contentType, {}, bucketName);
229
- return res.json({ success: true, uploadId: result.uploadId, key: result.key });
230
- } catch (err) {
231
- console.error('[mbkbucket] upload-init failed:', err);
232
- return sendApiError(res, err, 'upload-init failed');
233
- }
234
- }
235
-
236
- export async function uploadChunk(req, res) {
237
- try {
238
- const { uploadId, key, partNumber } = req.body;
239
- if (!req.file || !uploadId || !key || !partNumber) {
240
- return res.status(400).json({ success: false, error: 'chunk file, uploadId, key, and partNumber are required' });
241
- }
242
- const part = parseInt(partNumber, 10);
243
- if (isNaN(part) || part < 1 || part > 10000) {
244
- return res.status(400).json({ success: false, error: 'partNumber must be an integer between 1 and 10000' });
245
- }
246
-
247
- const result = await uploadPart(key, uploadId, part, req.file.buffer, req.activeBucket);
248
- return res.json({ success: true, partNumber: result.partNumber, ETag: result.ETag });
249
- } catch (err) {
250
- console.error('[mbkbucket] upload-chunk failed:', err);
251
- return sendApiError(res, err, 'upload-chunk failed');
252
- }
253
- }
254
-
255
- export async function completeUpload(req, res) {
256
- try {
257
- const bucketName = req.activeBucket;
258
- const { uploadId, key, parts } = req.body;
259
- if (!uploadId || !key || !Array.isArray(parts) || !parts.length) {
260
- return res.status(400).json({ success: false, error: 'uploadId, key, and parts array are required' });
261
- }
262
-
263
- const result = await completeMultipartUpload(key, uploadId, parts, bucketName);
264
- await cleanupFolderMarker(getFolderPath(result.key), bucketName);
265
-
266
- return res.json({ success: true, message: 'File uploaded successfully', key: result.key });
267
- } catch (err) {
268
- console.error('[mbkbucket] upload-complete failed:', err);
269
- return sendApiError(res, err, 'upload-complete failed');
270
- }
271
- }
272
-
273
- export async function abortUpload(req, res) {
274
- try {
275
- const { uploadId, key } = req.body;
276
- if (!uploadId || !key) return res.status(400).json({ success: false, error: 'uploadId and key are required' });
277
-
278
- await abortMultipartUpload(key, uploadId, req.activeBucket);
279
- return res.json({ success: true, message: 'Multipart upload aborted' });
280
- } catch (err) {
281
- console.error('[mbkbucket] upload-abort failed:', err);
282
- return sendApiError(res, err, 'upload-abort failed');
283
- }
284
- }
285
-
286
- export async function createFolder(req, res) {
287
- try {
288
- const bucketName = req.activeBucket;
289
- const { prefix = '', folderName } = req.body;
290
- if (!folderName) return res.status(400).json({ success: false, error: 'folderName is required' });
291
-
292
- const effectivePrefix = resolvePrefix(req, res, prefix);
293
- if (effectivePrefix === null) return;
294
-
295
- const cleanName = String(folderName).replace(/^\/+|\/+$/g, '');
296
- const cleanPrefix = effectivePrefix.replace(/\/+$/, '');
297
- const folderKey = (effectivePrefix && !cleanName.startsWith(effectivePrefix))
298
- ? `${cleanPrefix}/${cleanName}/`
299
- : `${cleanName}/`;
300
-
301
- if (await fileExists(folderKey, bucketName)) {
302
- return res.status(409).json({ success: false, error: 'Folder already exists' });
303
- }
304
-
305
- const existingFiles = await listfiles(folderKey, { maxKeys: 1, bucketName });
306
- if (existingFiles.Contents?.length) {
307
- return res.json({ success: true, message: 'Folder already exists with content', key: folderKey, skipMarker: true });
308
- }
309
-
310
- await uploadFile(folderKey, Buffer.alloc(0), 'application/x-empty', {
311
- metadata: { folder: 'true', marker: 'true' },
312
- bucketName
313
- });
314
-
315
- return res.json({ success: true, message: 'Folder created', key: folderKey });
316
- } catch (err) {
317
- console.error('Create folder failed:', err);
318
- return sendApiError(res, err, 'Create folder failed');
319
- }
320
- }
321
-
322
- export async function deleteItems(req, res) {
323
- try {
324
- const bucketName = req.activeBucket;
325
- const { key, keys, folder } = req.body;
326
-
327
- if (keys && Array.isArray(keys) && keys.length) {
328
- await deleteFiles(keys, bucketName);
329
- return res.json({ success: true, message: 'Files deleted successfully' });
330
- }
331
-
332
- if (!key) return res.status(400).json({ success: false, error: 'Key is required' });
333
-
334
- let keyToDelete;
335
- try {
336
- keyToDelete = ensureKeyHasAppPrefix(key);
337
- } catch (e) {
338
- return res.status(400).json({ success: false, error: e.message });
339
- }
340
-
341
- if (folder === true || String(keyToDelete).endsWith('/')) {
342
- await deleteFolder(keyToDelete, bucketName);
343
- return res.json({ success: true, message: 'Folder deleted successfully' });
344
- }
345
-
346
- await deleteFile(keyToDelete, bucketName);
347
- res.json({ success: true, message: 'File deleted successfully' });
348
- } catch (error) {
349
- console.error("Error deleting file:", error);
350
- return sendApiError(res, error, 'Delete failed');
351
- }
352
- }
353
-
354
- export async function downloadFileHandler(req, res) {
355
- let aborted = false;
356
- req.once('aborted', () => { aborted = true; });
357
- req.once('close', () => { aborted = true; });
358
-
359
- try {
360
- const key = normalizeKeyParam(req.params.key);
361
- let keyToUse;
362
- try {
363
- keyToUse = ensureKeyHasAppPrefix(key);
364
- } catch (e) {
365
- return res.status(400).json({ success: false, error: e.message });
366
- }
367
-
368
- if (aborted) return;
369
-
370
- const result = await downloadFile(keyToUse, { bucketName: req.activeBucket });
371
- if (aborted) {
372
- destroyStream(result.Body);
373
- return;
374
- }
375
-
376
- const fileName = getBaseName(keyToUse).replace(/"/g, '\\"');
377
- const contentType = result.ContentType || 'application/octet-stream';
378
- const contentLength = result.ContentLength;
379
-
380
- res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
381
- res.setHeader('Content-Type', contentType);
382
- if (contentLength) res.setHeader('Content-Length', contentLength);
383
- if (result.ETag) res.setHeader('ETag', result.ETag);
384
- if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
385
-
386
- const isSensitive = isSensitiveTextType(contentType);
387
- const isLargeFile = Number(contentLength || 0) > 50 * 1024 * 1024;
388
- res.setHeader('Cache-Control', isSensitive ? 'private, no-store' : isLargeFile ? 'private, max-age=600, must-revalidate' : 'private, max-age=1800, must-revalidate');
389
-
390
- try {
391
- await pipeline(result.Body, res);
392
- } catch (pipeErr) {
393
- if (aborted) {
394
- destroyStream(result.Body);
395
- return;
396
- }
397
- throw pipeErr;
398
- }
399
- } catch (error) {
400
- if (aborted) return;
401
- console.error("Error downloading file:", error);
402
- if (error.message?.includes('File not found')) {
403
- res.status(404).json({ message: "File not found", key: req.params.key });
404
- } else if (error.message?.includes('Access denied')) {
405
- res.status(403).json({ message: "Access denied" });
406
- } else if (!res.headersSent) {
407
- res.status(500).json({ message: "Download failed", error: error.message });
408
- }
409
- }
410
- }
1
+ import { pipeline } from "stream/promises";
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 { sendApiError, classifyApiError } from "../utils/errors.js";
4
+ import { getBaseName, isSensitiveTextType, buildKey, normalizeKeyParam, getFolderPath, destroyStream } from "../utils/helpers.js";
5
+ import { createLogger } from "#logger";
6
+
7
+ const debugApi = createLogger('api');
8
+
9
+ function resolvePrefix(req, res, prefix) {
10
+ try {
11
+ return ensurePrefix(prefix);
12
+ } catch (e) {
13
+ res.status(400).json({ success: false, error: e.message });
14
+ return null;
15
+ }
16
+ }
17
+
18
+ async function cleanupFolderMarker(folderPath, bucketName) {
19
+ if (!folderPath) return;
20
+ try {
21
+ const provider = getStorageProvider(bucketName);
22
+ if (provider.capabilities.nativeFolders) return;
23
+
24
+ const meta = await getFileMetadata(folderPath, bucketName);
25
+ if (meta.exists && (meta.ContentLength === 0 || meta.Metadata?.marker === 'true')) {
26
+ await deleteFile(folderPath, bucketName);
27
+ debugApi('Cleaned up folder marker: %s', folderPath);
28
+ }
29
+ } catch (err) {
30
+ debugApi('Failed to clean up folder marker: %s', err.message);
31
+ }
32
+ }
33
+
34
+ export async function cleanupUploads(req, res) {
35
+ try {
36
+ const { olderThanDays = 7, prefix = '' } = req.body;
37
+ const days = parseInt(olderThanDays, 10);
38
+ if (isNaN(days) || days < 1) {
39
+ return res.status(400).json({ success: false, error: 'olderThanDays must be a positive integer' });
40
+ }
41
+
42
+ debugApi('Starting cleanup of incomplete uploads older than %s days', days);
43
+ const result = await cleanupIncompleteMultipartUploads(days, prefix, req.activeBucket);
44
+
45
+ res.json({
46
+ success: true,
47
+ message: `Cleaned up ${result.abortedCount} incomplete multipart upload(s)`,
48
+ ...result
49
+ });
50
+ } catch (error) {
51
+ console.error('[mbkbucket] Cleanup failed:', error);
52
+ return sendApiError(res, error, 'Cleanup failed');
53
+ }
54
+ }
55
+
56
+ export async function listIncompleteUploads(req, res) {
57
+ try {
58
+ const { prefix = '' } = req.query;
59
+ const uploads = await listIncompleteMultipartUploads(prefix, req.activeBucket);
60
+ res.json({
61
+ success: true,
62
+ count: uploads.length,
63
+ uploads: uploads.map(u => ({
64
+ key: u.Key,
65
+ uploadId: u.UploadId,
66
+ initiated: u.Initiated,
67
+ initiator: u.Initiator?.DisplayName
68
+ }))
69
+ });
70
+ } catch (error) {
71
+ console.error('[mbkbucket] Failed to list incomplete uploads:', error);
72
+ return sendApiError(res, error, 'Failed to list incomplete uploads');
73
+ }
74
+ }
75
+
76
+ export async function listFiles(req, res) {
77
+ const { prefix = '', page = '1', search = '', recursive = 'true', token = '' } = req.query;
78
+ const pageSize = 100;
79
+ const bucketName = req.activeBucket;
80
+
81
+ const effectivePrefix = resolvePrefix(req, res, prefix);
82
+ if (effectivePrefix === null) return;
83
+
84
+ const isSearch = Boolean(search && search.trim());
85
+ const isRecursive = recursive === 'true';
86
+ const useOptimizedListing = !isSearch && !isRecursive;
87
+
88
+ try {
89
+ let files = [];
90
+ let folders = [];
91
+ let nextContinuationToken = null;
92
+
93
+ if (useOptimizedListing) {
94
+ let listPrefix = effectivePrefix ? String(effectivePrefix).replace(/^\/+/, '') : '';
95
+ if (listPrefix && !listPrefix.endsWith('/')) listPrefix += '/';
96
+
97
+ const result = await listfiles(listPrefix, {
98
+ continuationToken: token || undefined,
99
+ delimiter: '/',
100
+ maxKeys: 1000,
101
+ bucketName
102
+ });
103
+
104
+ files = (result.Contents || []).filter(f => !f.Key.endsWith('/'));
105
+ folders = (result.CommonPrefixes || [])
106
+ .filter(p => p.Prefix !== listPrefix && p.Prefix !== effectivePrefix)
107
+ .map(p => p.Prefix);
108
+ nextContinuationToken = result.NextContinuationToken;
109
+
110
+ return res.json({
111
+ success: true,
112
+ files,
113
+ folders,
114
+ prefix: effectivePrefix,
115
+ search,
116
+ currentPage: 1,
117
+ totalPages: 1,
118
+ hasNextPage: !!nextContinuationToken,
119
+ hasPrevPage: false,
120
+ nextContinuationToken,
121
+ mode: 'optimized',
122
+ totalFiles: -1,
123
+ paginationType: 'continuation-token'
124
+ });
125
+ }
126
+
127
+ let continuationToken;
128
+ do {
129
+ const result = await listfiles(effectivePrefix, { continuationToken, bucketName });
130
+ (result.Contents || []).forEach(file => {
131
+ if (!file.Key.endsWith('/')) files.push(file);
132
+ });
133
+ continuationToken = result.nextToken;
134
+ } while (continuationToken);
135
+
136
+ if (!files.length && !search) {
137
+ try {
138
+ const rootResult = await listfiles('', { bucketName });
139
+ if (rootResult.Contents?.length) files = rootResult.Contents;
140
+ } catch (fallbackErr) {
141
+ debugApi('Fallback root listing failed: %s', fallbackErr.message);
142
+ }
143
+ }
144
+
145
+ if (isSearch) {
146
+ const searchLower = search.trim().toLowerCase();
147
+ files = files.filter(file => file.Key.toLowerCase().includes(searchLower));
148
+ }
149
+
150
+ const totalFiles = files.length;
151
+ const totalPages = Math.max(1, Math.ceil(totalFiles / pageSize));
152
+ const currentPage = Math.max(1, parseInt(page, 10) || 1);
153
+ const paginatedFiles = files.slice((currentPage - 1) * pageSize, currentPage * pageSize);
154
+
155
+ res.json({
156
+ success: true,
157
+ files: paginatedFiles,
158
+ folders: [],
159
+ prefix: effectivePrefix,
160
+ search,
161
+ currentPage,
162
+ totalPages,
163
+ hasNextPage: currentPage < totalPages,
164
+ hasPrevPage: currentPage > 1,
165
+ nextPage: currentPage + 1,
166
+ prevPage: Math.max(1, currentPage - 1),
167
+ totalFiles
168
+ });
169
+ } catch (error) {
170
+ console.error("Error listing files:", error);
171
+ const mapped = classifyApiError(error, 'Error listing files');
172
+ return res.status(mapped.status).json({
173
+ success: false,
174
+ code: mapped.code,
175
+ error: mapped.message,
176
+ files: [],
177
+ totalFiles: 0
178
+ });
179
+ }
180
+ }
181
+
182
+ export async function uploadSingleFile(req, res) {
183
+ try {
184
+ const bucketName = req.activeBucket;
185
+ if (!req.file) return res.status(400).json({ success: false, error: 'No file selected' });
186
+
187
+ const effectivePrefix = resolvePrefix(req, res, req.body.prefix || '');
188
+ if (effectivePrefix === null) return;
189
+
190
+ const key = buildKey(effectivePrefix, req.file.originalname);
191
+ const uploadOptions = {
192
+ metadata: {
193
+ 'original-name': req.file.originalname,
194
+ 'upload-source': 'web-portal',
195
+ 'user-agent': req.headers['user-agent'] || 'unknown'
196
+ },
197
+ bucketName,
198
+ preventOverwrite: true
199
+ };
200
+
201
+ try {
202
+ await uploadFile(key, req.file.buffer, req.file.mimetype, uploadOptions);
203
+ await cleanupFolderMarker(getFolderPath(key), bucketName);
204
+ res.json({ success: true, message: 'File uploaded successfully', key });
205
+ } catch (uploadError) {
206
+ if (uploadError.message.includes('already exists')) {
207
+ return res.status(409).json({ success: false, error: `File already exists: ${req.file.originalname}` });
208
+ }
209
+ throw uploadError;
210
+ }
211
+ } catch (error) {
212
+ console.error("Error uploading file:", error);
213
+ return sendApiError(res, error, 'Upload failed');
214
+ }
215
+ }
216
+
217
+ export async function initiateMultipartUpload(req, res) {
218
+ try {
219
+ const bucketName = req.activeBucket;
220
+ const provider = getStorageProvider(bucketName);
221
+ if (!provider.capabilities.multipart) {
222
+ return res.status(501).json({
223
+ success: false,
224
+ error: `Multipart upload is not supported by provider '${provider.type}'. Use single-file direct upload.`
225
+ });
226
+ }
227
+
228
+ const { fileName, prefix = '', contentType = 'application/octet-stream' } = req.body;
229
+ if (!fileName) return res.status(400).json({ success: false, error: 'fileName is required' });
230
+
231
+ const effectivePrefix = resolvePrefix(req, res, prefix);
232
+ if (effectivePrefix === null) return;
233
+
234
+ const key = buildKey(effectivePrefix, fileName);
235
+ if (await fileExists(key, bucketName)) {
236
+ return res.status(409).json({ success: false, error: `File already exists: ${fileName}` });
237
+ }
238
+
239
+ const result = await createMultipartUpload(key, contentType, {}, bucketName);
240
+ return res.json({ success: true, uploadId: result.uploadId, key: result.key });
241
+ } catch (err) {
242
+ console.error('[mbkbucket] upload-init failed:', err);
243
+ return sendApiError(res, err, 'upload-init failed');
244
+ }
245
+ }
246
+
247
+ export async function uploadChunk(req, res) {
248
+ try {
249
+ const { uploadId, key, partNumber } = req.body;
250
+ if (!req.file || !uploadId || !key || !partNumber) {
251
+ return res.status(400).json({ success: false, error: 'chunk file, uploadId, key, and partNumber are required' });
252
+ }
253
+ const part = parseInt(partNumber, 10);
254
+ if (isNaN(part) || part < 1 || part > 10000) {
255
+ return res.status(400).json({ success: false, error: 'partNumber must be an integer between 1 and 10000' });
256
+ }
257
+
258
+ const result = await uploadPart(key, uploadId, part, req.file.buffer, req.activeBucket);
259
+ return res.json({ success: true, partNumber: result.partNumber, ETag: result.ETag });
260
+ } catch (err) {
261
+ console.error('[mbkbucket] upload-chunk failed:', err);
262
+ return sendApiError(res, err, 'upload-chunk failed');
263
+ }
264
+ }
265
+
266
+ export async function completeUpload(req, res) {
267
+ try {
268
+ const bucketName = req.activeBucket;
269
+ const { uploadId, key, parts } = req.body;
270
+ if (!uploadId || !key || !Array.isArray(parts) || !parts.length) {
271
+ return res.status(400).json({ success: false, error: 'uploadId, key, and parts array are required' });
272
+ }
273
+
274
+ const result = await completeMultipartUpload(key, uploadId, parts, bucketName);
275
+ await cleanupFolderMarker(getFolderPath(result.key), bucketName);
276
+
277
+ return res.json({ success: true, message: 'File uploaded successfully', key: result.key });
278
+ } catch (err) {
279
+ console.error('[mbkbucket] upload-complete failed:', err);
280
+ return sendApiError(res, err, 'upload-complete failed');
281
+ }
282
+ }
283
+
284
+ export async function abortUpload(req, res) {
285
+ try {
286
+ const { uploadId, key } = req.body;
287
+ if (!uploadId || !key) return res.status(400).json({ success: false, error: 'uploadId and key are required' });
288
+
289
+ await abortMultipartUpload(key, uploadId, req.activeBucket);
290
+ return res.json({ success: true, message: 'Multipart upload aborted' });
291
+ } catch (err) {
292
+ console.error('[mbkbucket] upload-abort failed:', err);
293
+ return sendApiError(res, err, 'upload-abort failed');
294
+ }
295
+ }
296
+
297
+ export async function createFolder(req, res) {
298
+ try {
299
+ const bucketName = req.activeBucket;
300
+ const { prefix = '', folderName } = req.body;
301
+ if (!folderName) return res.status(400).json({ success: false, error: 'folderName is required' });
302
+
303
+ const effectivePrefix = resolvePrefix(req, res, prefix);
304
+ if (effectivePrefix === null) return;
305
+
306
+ const cleanName = String(folderName).replace(/^\/+|\/+$/g, '');
307
+ const cleanPrefix = effectivePrefix.replace(/\/+$/, '');
308
+ const folderKey = (effectivePrefix && !cleanName.startsWith(effectivePrefix))
309
+ ? `${cleanPrefix}/${cleanName}/`
310
+ : `${cleanName}/`;
311
+
312
+ const result = await createStorageFolder(folderKey, bucketName);
313
+ return res.json({ success: true, message: result.message || 'Folder created', key: result.key || folderKey });
314
+ } catch (err) {
315
+ console.error('Create folder failed:', err);
316
+ return sendApiError(res, err, 'Create folder failed');
317
+ }
318
+ }
319
+
320
+ export async function deleteItems(req, res) {
321
+ try {
322
+ const bucketName = req.activeBucket;
323
+ const { key, keys, folder } = req.body;
324
+
325
+ if (keys && Array.isArray(keys) && keys.length) {
326
+ await deleteFiles(keys, bucketName);
327
+ return res.json({ success: true, message: 'Files deleted successfully' });
328
+ }
329
+
330
+ if (!key) return res.status(400).json({ success: false, error: 'Key is required' });
331
+
332
+ let keyToDelete;
333
+ try {
334
+ keyToDelete = ensureKeyHasAppPrefix(key);
335
+ } catch (e) {
336
+ return res.status(400).json({ success: false, error: e.message });
337
+ }
338
+
339
+ if (folder === true || String(keyToDelete).endsWith('/')) {
340
+ await deleteFolder(keyToDelete, bucketName);
341
+ return res.json({ success: true, message: 'Folder deleted successfully' });
342
+ }
343
+
344
+ await deleteFile(keyToDelete, bucketName);
345
+ res.json({ success: true, message: 'File deleted successfully' });
346
+ } catch (error) {
347
+ console.error("Error deleting file:", error);
348
+ return sendApiError(res, error, 'Delete failed');
349
+ }
350
+ }
351
+
352
+ export async function downloadFileHandler(req, res) {
353
+ let aborted = false;
354
+ req.once('aborted', () => { aborted = true; });
355
+ req.once('close', () => { aborted = true; });
356
+
357
+ try {
358
+ const key = normalizeKeyParam(req.params.key);
359
+ let keyToUse;
360
+ try {
361
+ keyToUse = ensureKeyHasAppPrefix(key);
362
+ } catch (e) {
363
+ return res.status(400).json({ success: false, error: e.message });
364
+ }
365
+
366
+ if (aborted) return;
367
+
368
+ const result = await downloadFile(keyToUse, { bucketName: req.activeBucket });
369
+ if (aborted) {
370
+ destroyStream(result.Body);
371
+ return;
372
+ }
373
+
374
+ const fileName = getBaseName(keyToUse).replace(/"/g, '\\"');
375
+ const contentType = result.ContentType || 'application/octet-stream';
376
+ const contentLength = result.ContentLength;
377
+
378
+ res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
379
+ res.setHeader('Content-Type', contentType);
380
+ if (contentLength) res.setHeader('Content-Length', contentLength);
381
+ if (result.ETag) res.setHeader('ETag', result.ETag);
382
+ if (result.LastModified) res.setHeader('Last-Modified', new Date(result.LastModified).toUTCString());
383
+
384
+ const isSensitive = isSensitiveTextType(contentType);
385
+ const isLargeFile = Number(contentLength || 0) > 50 * 1024 * 1024;
386
+ res.setHeader('Cache-Control', isSensitive ? 'private, no-store' : isLargeFile ? 'private, max-age=600, must-revalidate' : 'private, max-age=1800, must-revalidate');
387
+
388
+ try {
389
+ await pipeline(result.Body, res);
390
+ } catch (pipeErr) {
391
+ if (aborted) {
392
+ destroyStream(result.Body);
393
+ return;
394
+ }
395
+ throw pipeErr;
396
+ }
397
+ } catch (error) {
398
+ if (aborted) return;
399
+ console.error("Error downloading file:", error);
400
+ if (error.message?.includes('File not found')) {
401
+ res.status(404).json({ message: "File not found", key: req.params.key });
402
+ } else if (error.message?.includes('Access denied')) {
403
+ res.status(403).json({ message: "Access denied" });
404
+ } else if (!res.headersSent) {
405
+ res.status(500).json({ message: "Download failed", error: error.message });
406
+ }
407
+ }
408
+ }
409
+
410
+ export async function moveItem(req, res) {
411
+ try {
412
+ const { sourceKey, destKey } = req.body;
413
+ if (!sourceKey || !destKey) {
414
+ return res.status(400).json({ success: false, error: 'sourceKey and destKey are required' });
415
+ }
416
+
417
+ const result = await moveFile(sourceKey, destKey, {}, req.activeBucket);
418
+ res.json({ success: true, message: `Moved ${sourceKey} to ${destKey}`, ...result });
419
+ } catch (error) {
420
+ console.error('[mbkbucket] Move failed:', error);
421
+ return sendApiError(res, error, 'Move failed');
422
+ }
423
+ }
424
+
425
+ export async function copyItem(req, res) {
426
+ try {
427
+ const { sourceKey, destKey } = req.body;
428
+ if (!sourceKey || !destKey) {
429
+ return res.status(400).json({ success: false, error: 'sourceKey and destKey are required' });
430
+ }
431
+
432
+ const result = await copyFile(sourceKey, destKey, {}, req.activeBucket);
433
+ res.json({ success: true, message: `Copied ${sourceKey} to ${destKey}`, ...result });
434
+ } catch (error) {
435
+ console.error('[mbkbucket] Copy failed:', error);
436
+ return sendApiError(res, error, 'Copy failed');
437
+ }
438
+ }
439
+
440
+ export async function getHealthStatus(req, res) {
441
+ try {
442
+ const health = await checkHealth(req.activeBucket);
443
+ res.json({ success: true, ...health });
444
+ } catch (error) {
445
+ return sendApiError(res, error, 'Health check failed');
446
+ }
447
+ }
448
+
449
+ export async function getFileInfo(req, res) {
450
+ try {
451
+ const key = normalizeKeyParam(req.params.key || req.query.key);
452
+ if (!key) return res.status(400).json({ success: false, error: 'key is required' });
453
+
454
+ const meta = await getFileMetadata(key, req.activeBucket);
455
+ res.json({ success: true, ...meta });
456
+ } catch (error) {
457
+ return sendApiError(res, error, 'Failed to get file info');
458
+ }
459
+ }