mbkbucket 2.1.0 → 3.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.
- package/.env.example +4 -2
- package/.github/PACKAGE.md +6 -0
- package/.mbkbucket.config.example.json +14 -0
- package/LICENSE +161 -335
- package/README.md +262 -81
- package/cli.js +977 -0
- package/index.js +18 -140
- package/lib/index.js +7 -7
- package/package.json +18 -6
- package/public/bucketadmin.js +41 -6
- package/src/app.js +110 -0
- package/src/cli/auth-helper.js +223 -0
- package/{lib → src}/config/index.js +26 -19
- package/{lib/routes/api.js → src/controllers/api.controller.js} +125 -221
- package/{lib/routes/index.js → src/controllers/dashboard.controller.js} +8 -36
- package/src/controllers/info.controller.js +37 -0
- package/src/controllers/view.controller.js +276 -0
- package/src/middleware/async-handler.js +38 -0
- package/src/middleware/bucket-resolver.js +43 -0
- package/src/middleware/error-handler.js +34 -0
- package/src/middleware/rate-limiter.js +30 -0
- package/src/middleware/security.js +43 -0
- package/src/routes/api.routes.js +66 -0
- package/src/routes/index.js +22 -0
- package/src/routes/info.routes.js +10 -0
- package/src/routes/view.routes.js +25 -0
- package/{lib/s3.js → src/services/s3.service.js} +116 -190
- package/src/utils/errors.js +76 -0
- package/src/utils/helpers.js +173 -0
- package/{lib/debug.js → src/utils/logger.js} +5 -0
- package/test/config-validation.test.js +1 -1
- package/views/bucket.handlebars +0 -1
- package/lib/routes/info.js +0 -28
- package/lib/routes/view.js +0 -531
- /package/{lib → src}/config/validation.js +0 -0
|
@@ -1,84 +1,42 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { pipeline } from "stream/promises";
|
|
2
|
+
import {
|
|
3
|
+
listfiles, uploadFile, deleteFile, deleteFiles, deleteFolder,
|
|
4
|
+
downloadFile, getFileMetadata, fileExists,
|
|
5
|
+
ensureKeyHasAppPrefix, ensurePrefix, getAppName,
|
|
6
|
+
createMultipartUpload, uploadPart, completeMultipartUpload,
|
|
7
|
+
abortMultipartUpload, listIncompleteMultipartUploads,
|
|
8
|
+
cleanupIncompleteMultipartUploads
|
|
9
|
+
} from "../services/s3.service.js";
|
|
10
|
+
import { sendApiError, classifyApiError } from "../utils/errors.js";
|
|
11
|
+
import { getBaseName } from "../utils/helpers.js";
|
|
12
|
+
import { createLogger } from "../utils/logger.js";
|
|
6
13
|
|
|
7
|
-
const router = express.Router();
|
|
8
14
|
const debugApi = createLogger('api');
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Helpers
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
13
19
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return { status: 404, code: 'NOT_FOUND', message };
|
|
25
|
-
}
|
|
26
|
-
if (lower.includes('access denied')) {
|
|
27
|
-
return { status: 403, code: 'ACCESS_DENIED', message };
|
|
28
|
-
}
|
|
29
|
-
if (lower.includes('required') || lower.includes('invalid') || lower.includes('must be')) {
|
|
30
|
-
return { status: 400, code: 'VALIDATION_ERROR', message };
|
|
20
|
+
/**
|
|
21
|
+
* Resolve & validate a prefix from request input. Returns the effective prefix
|
|
22
|
+
* or sends a 400 JSON error response (and returns null).
|
|
23
|
+
*/
|
|
24
|
+
function resolvePrefix(req, res, prefix) {
|
|
25
|
+
try {
|
|
26
|
+
return ensurePrefix(prefix);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
res.status(400).json({ success: false, error: e.message });
|
|
29
|
+
return null;
|
|
31
30
|
}
|
|
32
|
-
return { status: 500, code: 'INTERNAL_ERROR', message };
|
|
33
31
|
}
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Build a full S3 key from a prefix and filename, stripping trailing slashes.
|
|
35
|
+
*/
|
|
36
|
+
function buildKey(prefix, fileName) {
|
|
37
|
+
return `${prefix.replace(/\/+$/, '')}/${fileName}`;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
function getBaseName(path = '') {
|
|
41
|
-
const value = String(path || '');
|
|
42
|
-
const lastSlashIndex = value.lastIndexOf('/');
|
|
43
|
-
return lastSlashIndex === -1 ? value : value.substring(lastSlashIndex + 1);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Central bucket guard for all API routes under /mbkbucket
|
|
47
|
-
router.use((req, res, next) => {
|
|
48
|
-
if (req.bucketResolveError) {
|
|
49
|
-
return sendApiError(res, req.bucketResolveError, 'Invalid bucket selection');
|
|
50
|
-
}
|
|
51
|
-
if (!req.activeBucket) {
|
|
52
|
-
return sendApiError(res, new Error('No bucket selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.'));
|
|
53
|
-
}
|
|
54
|
-
next();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
// This is the maximum size we allow per file part. The dashboard uses the same
|
|
58
|
-
// size as its single-upload threshold and multipart chunk size.
|
|
59
|
-
const MAX_CHUNK_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
60
|
-
|
|
61
|
-
const upload = multer({
|
|
62
|
-
storage: multer.memoryStorage(),
|
|
63
|
-
limits: { fileSize: MAX_CHUNK_SIZE }
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
const uploadChunk = (req, res, next) => {
|
|
67
|
-
upload.single('chunk')(req, res, (err) => {
|
|
68
|
-
if (err) {
|
|
69
|
-
// Multer throws an error if the file is too large (or another multipart parsing issue occurs)
|
|
70
|
-
if (err.code === 'LIMIT_FILE_SIZE') {
|
|
71
|
-
return res.status(413).json({
|
|
72
|
-
success: false,
|
|
73
|
-
error: `Chunk too large (max ${Math.round(MAX_CHUNK_SIZE / (1024 * 1024))} MB).`,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
return next(err);
|
|
77
|
-
}
|
|
78
|
-
next();
|
|
79
|
-
});
|
|
80
|
-
};
|
|
81
|
-
|
|
82
40
|
async function cleanupFolderMarker(folderPath, bucketName) {
|
|
83
41
|
if (!folderPath) return;
|
|
84
42
|
|
|
@@ -93,13 +51,15 @@ async function cleanupFolderMarker(folderPath, bucketName) {
|
|
|
93
51
|
}
|
|
94
52
|
}
|
|
95
53
|
|
|
96
|
-
//
|
|
97
|
-
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Controller handlers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export async function cleanupUploads(req, res) {
|
|
98
59
|
try {
|
|
99
60
|
const { olderThanDays = 7, prefix = '' } = req.body;
|
|
100
61
|
const bucketName = req.activeBucket;
|
|
101
62
|
|
|
102
|
-
// Validate olderThanDays
|
|
103
63
|
const days = parseInt(olderThanDays, 10);
|
|
104
64
|
if (isNaN(days) || days < 1) {
|
|
105
65
|
return res.status(400).json({ success: false, error: 'olderThanDays must be a positive integer' });
|
|
@@ -117,10 +77,9 @@ router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async
|
|
|
117
77
|
console.error('[mbkbucket] Cleanup failed:', error);
|
|
118
78
|
return sendApiError(res, error, 'Cleanup failed');
|
|
119
79
|
}
|
|
120
|
-
}
|
|
80
|
+
}
|
|
121
81
|
|
|
122
|
-
|
|
123
|
-
router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
82
|
+
export async function listIncompleteUploads(req, res) {
|
|
124
83
|
try {
|
|
125
84
|
const { prefix = '' } = req.query;
|
|
126
85
|
const bucketName = req.activeBucket;
|
|
@@ -140,71 +99,52 @@ router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), asyn
|
|
|
140
99
|
console.error('[mbkbucket] Failed to list incomplete uploads:', error);
|
|
141
100
|
return sendApiError(res, error, 'Failed to list incomplete uploads');
|
|
142
101
|
}
|
|
143
|
-
}
|
|
102
|
+
}
|
|
144
103
|
|
|
145
|
-
|
|
146
|
-
router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
104
|
+
export async function listFiles(req, res) {
|
|
147
105
|
const { prefix = '', page = '1', search = '', recursive = 'true', token = '' } = req.query;
|
|
148
106
|
const pageSize = 100;
|
|
149
107
|
const bucketName = req.activeBucket;
|
|
150
|
-
let effectivePrefix;
|
|
151
|
-
try {
|
|
152
|
-
effectivePrefix = ensurePrefix(prefix);
|
|
153
|
-
} catch (e) {
|
|
154
|
-
return res.status(400).json({ success: false, error: e.message });
|
|
155
|
-
}
|
|
156
108
|
|
|
157
|
-
|
|
109
|
+
const effectivePrefix = resolvePrefix(req, res, prefix);
|
|
110
|
+
if (effectivePrefix === null) return;
|
|
111
|
+
|
|
158
112
|
const isSearch = search && search.trim().length > 0;
|
|
159
|
-
// If recursive is explicitly 'false', we respect it. Default to true if missing (though frontend should send it)
|
|
160
|
-
// Logic: Optimization works only if NOT recursive and NOT searching.
|
|
161
113
|
const isRecursive = recursive === 'true';
|
|
162
114
|
const useOptimizedListing = !isSearch && !isRecursive;
|
|
163
115
|
|
|
164
116
|
try {
|
|
165
117
|
let files = [];
|
|
166
|
-
let folders = [];
|
|
118
|
+
let folders = [];
|
|
167
119
|
let nextContinuationToken = null;
|
|
168
120
|
let usedPrefix = effectivePrefix;
|
|
169
121
|
let legacyFallback = false;
|
|
170
122
|
|
|
171
123
|
if (useOptimizedListing) {
|
|
172
|
-
// --- OPTIMIZED PATH: S3 Delimiter (Folder View) ---
|
|
173
|
-
// We process only one page from S3. This reduces API calls significantly.
|
|
174
|
-
|
|
175
|
-
// FIX: Ensure root listing uses trailing slash to prevent "app-name" matching "app-name-backup"
|
|
176
|
-
// and ensure directory browsing works correctly if ensurePrefix didn't append slash (which it now does if present in input).
|
|
177
|
-
// But for root, input is empty, ensurePrefix returns "app", so we force "app/" here.
|
|
178
124
|
let listPrefix = effectivePrefix;
|
|
179
125
|
if (listPrefix === getAppName() && !listPrefix.endsWith('/')) {
|
|
180
126
|
listPrefix += '/';
|
|
181
127
|
}
|
|
182
128
|
|
|
183
129
|
const result = await listfiles(listPrefix, {
|
|
184
|
-
continuationToken: token || undefined,
|
|
130
|
+
continuationToken: token || undefined,
|
|
185
131
|
delimiter: '/',
|
|
186
|
-
maxKeys: 1000,
|
|
132
|
+
maxKeys: 1000,
|
|
187
133
|
bucketName
|
|
188
134
|
});
|
|
189
135
|
|
|
190
|
-
// Filter out folder marker objects (zero-byte files ending with /)
|
|
191
136
|
files = (result.Contents || []).filter(f => !f.Key.endsWith('/'));
|
|
192
|
-
// S3 returns CommonPrefixes for folders when delimiter is used
|
|
193
137
|
folders = (result.CommonPrefixes || [])
|
|
194
138
|
.filter(p => p.Prefix !== effectivePrefix)
|
|
195
139
|
.map(p => p.Prefix);
|
|
196
140
|
nextContinuationToken = result.NextContinuationToken;
|
|
197
141
|
|
|
198
|
-
// Note: In optimized mode, totalFiles is unknown without scanning everything.
|
|
199
142
|
} else {
|
|
200
|
-
// --- LEGACY PATH: Recursive Scan (Search or Flat View) ---
|
|
201
143
|
let continuationToken = undefined;
|
|
202
144
|
|
|
203
|
-
// Fetch all files recursively (handling S3 pagination)
|
|
204
145
|
do {
|
|
205
146
|
const result = await listfiles(effectivePrefix, { continuationToken, bucketName });
|
|
206
147
|
if (result.Contents?.length) {
|
|
207
|
-
// Filter out folder marker objects (zero-byte files ending with /)
|
|
208
148
|
for (const file of result.Contents) {
|
|
209
149
|
if (!file.Key.endsWith('/')) files.push(file);
|
|
210
150
|
}
|
|
@@ -212,7 +152,6 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
212
152
|
continuationToken = result.nextToken;
|
|
213
153
|
} while (continuationToken);
|
|
214
154
|
|
|
215
|
-
// If no files found under the app prefixed path, try root as a legacy fallback
|
|
216
155
|
if (!files.length && !search) {
|
|
217
156
|
try {
|
|
218
157
|
const rootResult = await listfiles('', { bucketName });
|
|
@@ -226,34 +165,29 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
226
165
|
}
|
|
227
166
|
}
|
|
228
167
|
|
|
229
|
-
// Apply search filter if provided
|
|
230
168
|
if (isSearch) {
|
|
231
169
|
const searchLower = search.trim().toLowerCase();
|
|
232
170
|
files = files.filter(file => file.Key.toLowerCase().includes(searchLower));
|
|
233
171
|
}
|
|
234
172
|
}
|
|
235
173
|
|
|
236
|
-
// Response Construction
|
|
237
174
|
if (useOptimizedListing) {
|
|
238
175
|
res.json({
|
|
239
176
|
success: true,
|
|
240
|
-
files
|
|
241
|
-
folders
|
|
177
|
+
files,
|
|
178
|
+
folders,
|
|
242
179
|
prefix: effectivePrefix,
|
|
243
|
-
search
|
|
244
|
-
// In optimized mode, we use token-based pagination, not page numbers
|
|
180
|
+
search,
|
|
245
181
|
currentPage: 1,
|
|
246
|
-
totalPages: 1,
|
|
182
|
+
totalPages: 1,
|
|
247
183
|
hasNextPage: !!nextContinuationToken,
|
|
248
|
-
hasPrevPage: false,
|
|
249
|
-
nextContinuationToken
|
|
184
|
+
hasPrevPage: false,
|
|
185
|
+
nextContinuationToken,
|
|
250
186
|
mode: 'optimized',
|
|
251
|
-
totalFiles: -1,
|
|
252
|
-
paginationType: 'continuation-token'
|
|
187
|
+
totalFiles: -1,
|
|
188
|
+
paginationType: 'continuation-token'
|
|
253
189
|
});
|
|
254
|
-
|
|
255
190
|
} else {
|
|
256
|
-
// Legacy Pagination Logic (In-Memory)
|
|
257
191
|
const totalFiles = files.length;
|
|
258
192
|
const totalPages = Math.max(1, Math.ceil(totalFiles / pageSize));
|
|
259
193
|
const currentPage = Math.max(1, parseInt(page, 10) || 1);
|
|
@@ -263,10 +197,10 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
263
197
|
res.json({
|
|
264
198
|
success: true,
|
|
265
199
|
files: paginatedFiles,
|
|
266
|
-
folders: [],
|
|
200
|
+
folders: [],
|
|
267
201
|
prefix: effectivePrefix,
|
|
268
|
-
search
|
|
269
|
-
currentPage
|
|
202
|
+
search,
|
|
203
|
+
currentPage,
|
|
270
204
|
totalPages,
|
|
271
205
|
hasNextPage: currentPage < totalPages,
|
|
272
206
|
hasPrevPage: currentPage > 1,
|
|
@@ -287,10 +221,9 @@ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res)
|
|
|
287
221
|
totalFiles: 0
|
|
288
222
|
});
|
|
289
223
|
}
|
|
290
|
-
}
|
|
224
|
+
}
|
|
291
225
|
|
|
292
|
-
|
|
293
|
-
router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file'), async (req, res) => {
|
|
226
|
+
export async function uploadSingleFile(req, res) {
|
|
294
227
|
try {
|
|
295
228
|
const bucketName = req.activeBucket;
|
|
296
229
|
if (!req.file) {
|
|
@@ -298,14 +231,10 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
|
|
|
298
231
|
}
|
|
299
232
|
|
|
300
233
|
const { prefix = '' } = req.body;
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
effectivePrefix = ensurePrefix(prefix);
|
|
304
|
-
} catch (e) {
|
|
305
|
-
return res.status(400).json({ success: false, error: e.message });
|
|
306
|
-
}
|
|
234
|
+
const effectivePrefix = resolvePrefix(req, res, prefix);
|
|
235
|
+
if (effectivePrefix === null) return;
|
|
307
236
|
|
|
308
|
-
const key =
|
|
237
|
+
const key = buildKey(effectivePrefix, req.file.originalname);
|
|
309
238
|
|
|
310
239
|
const uploadOptions = {
|
|
311
240
|
metadata: {
|
|
@@ -314,19 +243,17 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
|
|
|
314
243
|
'user-agent': req.headers['user-agent'] || 'unknown'
|
|
315
244
|
},
|
|
316
245
|
bucketName,
|
|
317
|
-
preventOverwrite: true
|
|
246
|
+
preventOverwrite: true
|
|
318
247
|
};
|
|
319
248
|
|
|
320
249
|
try {
|
|
321
250
|
await uploadFile(key, req.file.buffer, req.file.mimetype, uploadOptions);
|
|
322
251
|
|
|
323
|
-
// Clean up folder marker if it exists in the parent directory.
|
|
324
252
|
const folderPath = key.substring(0, key.lastIndexOf('/') + 1);
|
|
325
253
|
await cleanupFolderMarker(folderPath, bucketName);
|
|
326
254
|
|
|
327
|
-
res.json({ success: true, message: 'File uploaded successfully', key
|
|
255
|
+
res.json({ success: true, message: 'File uploaded successfully', key });
|
|
328
256
|
} catch (uploadError) {
|
|
329
|
-
// Handle file already exists error from atomic operation
|
|
330
257
|
if (uploadError.message.includes('already exists')) {
|
|
331
258
|
return res.status(409).json({ success: false, error: `File already exists: ${req.file.originalname}` });
|
|
332
259
|
}
|
|
@@ -336,24 +263,19 @@ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file
|
|
|
336
263
|
console.error("Error uploading file:", error);
|
|
337
264
|
return sendApiError(res, error, 'Upload failed');
|
|
338
265
|
}
|
|
339
|
-
}
|
|
266
|
+
}
|
|
340
267
|
|
|
341
|
-
|
|
342
|
-
// Body: { fileName, prefix?, contentType? }
|
|
343
|
-
// Returns: { uploadId, key }
|
|
344
|
-
router.post('/upload-init', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
268
|
+
export async function initiateMultipartUpload(req, res) {
|
|
345
269
|
try {
|
|
346
270
|
const bucketName = req.activeBucket;
|
|
347
271
|
const { fileName, prefix = '', contentType = 'application/octet-stream' } = req.body;
|
|
348
272
|
if (!fileName) return res.status(400).json({ success: false, error: 'fileName is required' });
|
|
349
273
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
274
|
+
const effectivePrefix = resolvePrefix(req, res, prefix);
|
|
275
|
+
if (effectivePrefix === null) return;
|
|
276
|
+
|
|
277
|
+
const key = buildKey(effectivePrefix, fileName);
|
|
353
278
|
|
|
354
|
-
// Check if file exists before initiating multipart upload
|
|
355
|
-
// Note: This is still slightly racy, but multipart uploads take time so collision risk is lower
|
|
356
|
-
// A complete solution would require server-side locking or unique upload IDs
|
|
357
279
|
const exists = await fileExists(key, bucketName);
|
|
358
280
|
if (exists) return res.status(409).json({ success: false, error: `File already exists: ${fileName}` });
|
|
359
281
|
|
|
@@ -363,12 +285,9 @@ router.post('/upload-init', validateSessionAndRole('SuperAdmin'), async (req, re
|
|
|
363
285
|
console.error('[mbkbucket] upload-init failed:', err);
|
|
364
286
|
return sendApiError(res, err, 'upload-init failed');
|
|
365
287
|
}
|
|
366
|
-
}
|
|
288
|
+
}
|
|
367
289
|
|
|
368
|
-
|
|
369
|
-
// FormData: chunk (file), partNumber (1-based integer), uploadId, key
|
|
370
|
-
// Returns: { partNumber, ETag }
|
|
371
|
-
router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), uploadChunk, async (req, res) => {
|
|
290
|
+
export async function uploadChunk(req, res) {
|
|
372
291
|
try {
|
|
373
292
|
const bucketName = req.activeBucket;
|
|
374
293
|
const { uploadId, key, partNumber } = req.body;
|
|
@@ -386,12 +305,9 @@ router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), uploadChunk,
|
|
|
386
305
|
console.error('[mbkbucket] upload-chunk failed:', err);
|
|
387
306
|
return sendApiError(res, err, 'upload-chunk failed');
|
|
388
307
|
}
|
|
389
|
-
}
|
|
308
|
+
}
|
|
390
309
|
|
|
391
|
-
|
|
392
|
-
// Body: { uploadId, key, parts: [{ partNumber, ETag }] }
|
|
393
|
-
// Returns: { key }
|
|
394
|
-
router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
310
|
+
export async function completeUpload(req, res) {
|
|
395
311
|
try {
|
|
396
312
|
const bucketName = req.activeBucket;
|
|
397
313
|
const { uploadId, key, parts } = req.body;
|
|
@@ -401,7 +317,6 @@ router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req
|
|
|
401
317
|
|
|
402
318
|
const result = await completeMultipartUpload(key, uploadId, parts, bucketName);
|
|
403
319
|
|
|
404
|
-
// Clean up folder marker if it exists in the parent directory
|
|
405
320
|
const folderPath = result.key.substring(0, result.key.lastIndexOf('/') + 1);
|
|
406
321
|
await cleanupFolderMarker(folderPath, bucketName);
|
|
407
322
|
|
|
@@ -410,11 +325,9 @@ router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req
|
|
|
410
325
|
console.error('[mbkbucket] upload-complete failed:', err);
|
|
411
326
|
return sendApiError(res, err, 'upload-complete failed');
|
|
412
327
|
}
|
|
413
|
-
}
|
|
328
|
+
}
|
|
414
329
|
|
|
415
|
-
|
|
416
|
-
// Body: { uploadId, key }
|
|
417
|
-
router.post('/upload-abort', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
330
|
+
export async function abortUpload(req, res) {
|
|
418
331
|
try {
|
|
419
332
|
const bucketName = req.activeBucket;
|
|
420
333
|
const { uploadId, key } = req.body;
|
|
@@ -426,30 +339,26 @@ router.post('/upload-abort', validateSessionAndRole('SuperAdmin'), async (req, r
|
|
|
426
339
|
console.error('[mbkbucket] upload-abort failed:', err);
|
|
427
340
|
return sendApiError(res, err, 'upload-abort failed');
|
|
428
341
|
}
|
|
429
|
-
}
|
|
342
|
+
}
|
|
430
343
|
|
|
431
|
-
|
|
432
|
-
router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
344
|
+
export async function createFolder(req, res) {
|
|
433
345
|
try {
|
|
434
346
|
const bucketName = req.activeBucket;
|
|
435
347
|
const { prefix = '', folderName } = req.body;
|
|
436
348
|
if (!folderName) return res.status(400).json({ success: false, error: 'folderName is required' });
|
|
437
349
|
|
|
438
|
-
|
|
439
|
-
|
|
350
|
+
const effectivePrefix = resolvePrefix(req, res, prefix);
|
|
351
|
+
if (effectivePrefix === null) return;
|
|
440
352
|
|
|
441
353
|
const folderKey = `${effectivePrefix.replace(/\/+$/, '')}/${folderName}/`;
|
|
442
354
|
|
|
443
|
-
// Check if folder marker already exists
|
|
444
355
|
const markerExists = await fileExists(folderKey, bucketName);
|
|
445
356
|
if (markerExists) return res.status(409).json({ success: false, error: 'Folder already exists' });
|
|
446
357
|
|
|
447
|
-
// Check if any files already exist under this prefix (folder already has content)
|
|
448
358
|
const existingFiles = await listfiles(folderKey, { maxKeys: 1, bucketName });
|
|
449
359
|
const hasContent = existingFiles.Contents && existingFiles.Contents.length > 0;
|
|
450
360
|
|
|
451
361
|
if (hasContent) {
|
|
452
|
-
// Folder already exists with content, no need to create marker
|
|
453
362
|
return res.json({
|
|
454
363
|
success: true,
|
|
455
364
|
message: 'Folder already exists with content',
|
|
@@ -458,7 +367,6 @@ router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req,
|
|
|
458
367
|
});
|
|
459
368
|
}
|
|
460
369
|
|
|
461
|
-
// Create zero-byte object to represent empty folder
|
|
462
370
|
await uploadFile(folderKey, Buffer.alloc(0), 'application/x-empty', {
|
|
463
371
|
metadata: { 'folder': 'true', 'marker': 'true' },
|
|
464
372
|
bucketName
|
|
@@ -469,15 +377,14 @@ router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req,
|
|
|
469
377
|
console.error('Create folder failed:', err);
|
|
470
378
|
return sendApiError(res, err, 'Create folder failed');
|
|
471
379
|
}
|
|
472
|
-
}
|
|
380
|
+
}
|
|
473
381
|
|
|
474
|
-
|
|
382
|
+
export async function deleteItems(req, res) {
|
|
475
383
|
try {
|
|
476
384
|
const bucketName = req.activeBucket;
|
|
477
385
|
const { key, keys, folder } = req.body;
|
|
478
386
|
|
|
479
387
|
if (keys && Array.isArray(keys) && keys.length) {
|
|
480
|
-
// Bulk delete
|
|
481
388
|
await deleteFiles(keys, bucketName);
|
|
482
389
|
return res.json({ success: true, message: 'Files deleted successfully' });
|
|
483
390
|
}
|
|
@@ -487,7 +394,6 @@ router.post('/delete', validateSessionAndRole('SuperAdmin'), async (req, res) =>
|
|
|
487
394
|
let keyToDelete;
|
|
488
395
|
try { keyToDelete = ensureKeyHasAppPrefix(key); } catch (e) { return res.status(400).json({ success: false, error: e.message }); }
|
|
489
396
|
|
|
490
|
-
// If caller indicates it's a folder (or key ends with '/'), delete all objects under the prefix
|
|
491
397
|
if (folder === true || String(keyToDelete).endsWith('/')) {
|
|
492
398
|
await deleteFolder(keyToDelete, bucketName);
|
|
493
399
|
return res.json({ success: true, message: 'Folder deleted successfully' });
|
|
@@ -499,31 +405,43 @@ router.post('/delete', validateSessionAndRole('SuperAdmin'), async (req, res) =>
|
|
|
499
405
|
console.error("Error deleting file:", error);
|
|
500
406
|
return sendApiError(res, error, 'Delete failed');
|
|
501
407
|
}
|
|
502
|
-
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function downloadFileHandler(req, res) {
|
|
411
|
+
// Track whether this request has been aborted (client disconnected)
|
|
412
|
+
let aborted = false;
|
|
413
|
+
req.once('aborted', () => { aborted = true; });
|
|
414
|
+
req.once('close', () => { aborted = true; });
|
|
503
415
|
|
|
504
|
-
router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
|
|
505
|
-
let stream = null;
|
|
506
416
|
try {
|
|
507
417
|
const bucketName = req.activeBucket;
|
|
508
|
-
const key = req.params.key;
|
|
509
418
|
let keyToUse;
|
|
510
419
|
try {
|
|
511
|
-
keyToUse = ensureKeyHasAppPrefix(key);
|
|
420
|
+
keyToUse = ensureKeyHasAppPrefix(req.params.key);
|
|
512
421
|
} catch (e) {
|
|
513
422
|
return res.status(400).json({ success: false, error: e.message });
|
|
514
423
|
}
|
|
515
|
-
const result = await downloadFile(keyToUse, { bucketName });
|
|
516
|
-
stream = result.Body;
|
|
517
424
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
res.setHeader('Content-Type', contentType);
|
|
425
|
+
// If client already disconnected before we fetched, bail early
|
|
426
|
+
if (aborted) return;
|
|
521
427
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
428
|
+
const result = await downloadFile(keyToUse, { bucketName });
|
|
429
|
+
|
|
430
|
+
// Re-check: client may have disconnected while waiting for S3
|
|
431
|
+
if (aborted) {
|
|
432
|
+
if (result.Body && typeof result.Body.destroy === 'function') result.Body.destroy();
|
|
433
|
+
return;
|
|
525
434
|
}
|
|
526
435
|
|
|
436
|
+
const fileName = getBaseName(keyToUse).replace(/"/g, '\\"');
|
|
437
|
+
const contentType = result.ContentType || 'application/octet-stream';
|
|
438
|
+
const contentLength = result.ContentLength;
|
|
439
|
+
|
|
440
|
+
// Set headers BEFORE piping — this allows the browser to show filename
|
|
441
|
+
// and progress bar even before the first byte arrives
|
|
442
|
+
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
|
443
|
+
res.setHeader('Content-Type', contentType);
|
|
444
|
+
if (contentLength) res.setHeader('Content-Length', contentLength);
|
|
527
445
|
if (result.ETag) res.setHeader('ETag', result.ETag);
|
|
528
446
|
if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
|
|
529
447
|
|
|
@@ -533,9 +451,8 @@ router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req
|
|
|
533
451
|
|| typeLower.includes('xml')
|
|
534
452
|
|| typeLower.includes('javascript')
|
|
535
453
|
|| typeLower.includes('typescript');
|
|
536
|
-
const isLargeFile = Number(
|
|
454
|
+
const isLargeFile = Number(contentLength || 0) > 50 * 1024 * 1024;
|
|
537
455
|
|
|
538
|
-
// Smarter cache policy for authenticated downloads.
|
|
539
456
|
if (isSensitiveText) {
|
|
540
457
|
res.setHeader('Cache-Control', 'private, no-store');
|
|
541
458
|
} else if (isLargeFile) {
|
|
@@ -544,42 +461,29 @@ router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req
|
|
|
544
461
|
res.setHeader('Cache-Control', 'private, max-age=1800, must-revalidate');
|
|
545
462
|
}
|
|
546
463
|
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
464
|
+
// Use pipeline for proper backpressure + cleanup. On abort, the
|
|
465
|
+
// pipeline will be rejected and we clean up the S3 stream.
|
|
466
|
+
try {
|
|
467
|
+
await pipeline(result.Body, res);
|
|
468
|
+
} catch (pipeErr) {
|
|
469
|
+
// Aborted by client — expected, not an error
|
|
470
|
+
if (aborted) {
|
|
471
|
+
if (result.Body && typeof result.Body.destroy === 'function') result.Body.destroy();
|
|
472
|
+
return;
|
|
552
473
|
}
|
|
553
|
-
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
//
|
|
557
|
-
|
|
558
|
-
if (stream && typeof stream.destroy === 'function') stream.destroy();
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
// Handle request timeout
|
|
562
|
-
const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
|
|
563
|
-
req.setTimeout(timeoutDuration, () => {
|
|
564
|
-
console.error(`Download timeout (${timeoutDuration}ms) for: ${keyToUse}`);
|
|
565
|
-
if (stream && typeof stream.destroy === 'function') stream.destroy();
|
|
566
|
-
});
|
|
474
|
+
throw pipeErr;
|
|
475
|
+
}
|
|
476
|
+
} catch (error) {
|
|
477
|
+
// Don't try to send error response if client already disconnected
|
|
478
|
+
if (aborted) return;
|
|
567
479
|
|
|
568
|
-
stream.pipe(res);
|
|
569
|
-
}
|
|
570
|
-
catch (error) {
|
|
571
480
|
console.error("Error downloading file:", error);
|
|
572
|
-
|
|
573
|
-
// Clean up stream on error
|
|
574
|
-
if (stream && typeof stream.destroy === 'function') stream.destroy();
|
|
575
|
-
if (error.message.includes('File not found')) {
|
|
481
|
+
if (error.message && error.message.includes('File not found')) {
|
|
576
482
|
res.status(404).json({ message: "File not found", key: req.params.key });
|
|
577
|
-
} else if (error.message.includes('Access denied')) {
|
|
483
|
+
} else if (error.message && error.message.includes('Access denied')) {
|
|
578
484
|
res.status(403).json({ message: "Access denied" });
|
|
579
|
-
} else {
|
|
485
|
+
} else if (!res.headersSent) {
|
|
580
486
|
res.status(500).json({ message: "Download failed", error: error.message });
|
|
581
487
|
}
|
|
582
488
|
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
export default router;
|
|
489
|
+
}
|