mbkbucket 1.3.0 → 1.5.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.
@@ -1,4 +1,7 @@
1
1
  import { createRequire } from "module";
2
+ import dotenv from "dotenv";
3
+
4
+ dotenv.config();
2
5
 
3
6
  // Load package.json from mbkbucket package (not parent project)
4
7
  const require = createRequire(import.meta.url);
@@ -54,4 +57,134 @@ async function checkVersion() {
54
57
  console.warn(`[mbkbucket] Failed to check for updates: ${error.message}`);
55
58
  }
56
59
  }
57
- export { packageJson, appVersion, getLatestVersion, checkVersion };
60
+
61
+ // Comprehensive validation function
62
+ function validateConfiguration() {
63
+ const errors = [];
64
+
65
+ // Parse and validate mbkbucketVar
66
+ let mbkbucketVar;
67
+ try {
68
+ if (!process.env.mbkbucketVar) {
69
+ // If mbkbucketVar is not defined, use empty object (defaults will be applied later)
70
+ mbkbucketVar = {};
71
+ console.log(`[mbkbucket] mbkbucketVar not defined, using defaults`);
72
+ } else {
73
+ mbkbucketVar = JSON.parse(process.env.mbkbucketVar);
74
+ }
75
+ } catch (error) {
76
+ errors.push("Invalid JSON in process.env.mbkbucketVar");
77
+ throw new Error(`[mbkbucket] Configuration Error:\n - ${errors.join('\n - ')}`);
78
+ }
79
+
80
+ if (!mbkbucketVar || typeof mbkbucketVar !== 'object') {
81
+ errors.push("mbkbucketVar must be a valid object");
82
+ throw new Error(`[mbkbucket] Configuration Error:\n - ${errors.join('\n - ')}`);
83
+ }
84
+
85
+ // Apply defaults for optional keys (booleans now)
86
+ const defaults = {
87
+ p_view_inline: true,
88
+ publiView_enabled: false
89
+ };
90
+
91
+ const usedDefaults = [];
92
+ Object.entries(defaults).forEach(([key, defaultVal]) => {
93
+ const current = mbkbucketVar[key];
94
+ if (current === undefined || (typeof current === 'string' && current.trim() === '')) {
95
+ mbkbucketVar[key] = defaultVal;
96
+ usedDefaults.push(key);
97
+ }
98
+ });
99
+
100
+ // Helper to normalize various truthy/falsy representations
101
+ function normalizeBool(val) {
102
+ if (typeof val === 'boolean') return val;
103
+ if (typeof val === 'string') {
104
+ const norm = val.trim().toLowerCase();
105
+ if (['true', 't', '1', 'yes', 'y', 'on'].includes(norm)) return true;
106
+ if (['false', 'f', '0', 'no', 'n', 'off'].includes(norm)) return false;
107
+ return val; // return original so later validation will flag it
108
+ }
109
+ return val;
110
+ }
111
+
112
+ // Normalize boolean-like values early so validation works on cleaned values;
113
+ // results are actual booleans.
114
+ ['p_view_inline', 'publiView_enabled'].forEach(k => {
115
+ if (k in mbkbucketVar) {
116
+ mbkbucketVar[k] = normalizeBool(mbkbucketVar[k]);
117
+ }
118
+ });
119
+
120
+ // Validate that applied values are proper booleans (after defaults and normalization)
121
+ // Note: p_view_inline and publiView_enabled are optional - defaults are applied above
122
+ ['p_view_inline', 'publiView_enabled'].forEach(key => {
123
+ if (mbkbucketVar[key] !== undefined && typeof mbkbucketVar[key] !== 'boolean') {
124
+ errors.push(`mbkbucketVar.${key} must be a boolean if provided`);
125
+ }
126
+ });
127
+
128
+ if (errors.length > 0) {
129
+ throw new Error(`[mbkbucket] Configuration Validation Failed:\n - ${errors.join('\n - ')}`);
130
+ }
131
+
132
+ const summary = usedDefaults.length > 0 ? ` (defaults applied: ${usedDefaults.join(', ')})` : '';
133
+ console.log(`[mbkbucket] Configuration loaded${summary}`);
134
+ return mbkbucketVar;
135
+ }
136
+
137
+ // Validate BucketConnection configuration
138
+ function validateBucketConnection() {
139
+ if (!process.env.BucketConnection) {
140
+ console.warn(`[mbkbucket] ⚠️ BucketConnection not configured. You need to set this environment variable.`);
141
+ console.warn(`[mbkbucket] Example format:`);
142
+ console.warn(`[mbkbucket] BucketConnection={"r2":{"BUCKET_NAME":"my-bucket","ACCESS_KEY_ID":"key","SECRET_ACCESS_KEY":"secret","ENDPOINT":"https://..."}}`);
143
+ return;
144
+ }
145
+
146
+ try {
147
+ const bucketConfig = JSON.parse(process.env.BucketConnection);
148
+
149
+ if (!bucketConfig || typeof bucketConfig !== 'object' || Object.keys(bucketConfig).length === 0) {
150
+ throw new Error('BucketConnection must be a non-empty object');
151
+ }
152
+
153
+ // Validate each bucket configuration
154
+ const bucketNames = Object.keys(bucketConfig);
155
+ for (const bucketName of bucketNames) {
156
+ const config = bucketConfig[bucketName];
157
+
158
+ if (!config || typeof config !== 'object') {
159
+ throw new Error(`Bucket '${bucketName}' configuration must be an object, not a string. Remove quotes around the inner JSON object.`);
160
+ }
161
+
162
+ const requiredFields = ['BUCKET_NAME', 'ACCESS_KEY_ID', 'SECRET_ACCESS_KEY', 'ENDPOINT'];
163
+ const missingFields = requiredFields.filter(field => !config[field]);
164
+
165
+ if (missingFields.length > 0) {
166
+ throw new Error(`Bucket '${bucketName}' is missing required fields: ${missingFields.join(', ')}`);
167
+ }
168
+ }
169
+
170
+ console.log(`[mbkbucket] ✓ BucketConnection validated (${bucketNames.length} bucket(s): ${bucketNames.join(', ')})`);
171
+ return bucketConfig;
172
+ } catch (error) {
173
+ console.error(`[mbkbucket] ❌ BucketConnection validation failed: ${error.message}`);
174
+ console.error(`[mbkbucket] Current value (excerpt): ${process.env.BucketConnection.slice(0, 200)}...`);
175
+ console.error(`[mbkbucket] Correct format example:`);
176
+ console.error(`[mbkbucket] BucketConnection={"r2":{"BUCKET_NAME":"my-bucket","ACCESS_KEY_ID":"key","SECRET_ACCESS_KEY":"secret","ENDPOINT":"https://endpoint.com"}}`);
177
+ console.error(`[mbkbucket] ⚠️ Note: Inner objects should NOT be quoted as strings!`);
178
+ throw new Error(`[mbkbucket] Invalid BucketConnection configuration`);
179
+ }
180
+ }
181
+
182
+ // Parse and validate mbkbucketVar once
183
+ const mbkbucketVar = validateConfiguration();
184
+ console.log(`[mbkbucket] mbkbucketVar configuration:`, mbkbucketVar);
185
+
186
+ // Validate BucketConnection
187
+ validateBucketConnection();
188
+
189
+ export { packageJson, appVersion, getLatestVersion, checkVersion };
190
+ export { mbkbucketVar };
package/lib/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // Barrel file: re-export all public items from lib
2
2
  export * from './s3.js';
3
3
  export * from './config/index.js';
4
- export { default as bucket } from './bucket.js';
4
+ export { default as bucket } from './routes/index.js';
5
5
 
6
6
  import * as s3 from './s3.js';
7
7
  import * as config from './config/index.js';
8
- import bucket from './bucket.js';
8
+ import bucket from './routes/index.js';
9
9
 
10
10
  // Default aggregate export for convenience
11
11
  const defaultExport = {
@@ -0,0 +1,501 @@
1
+ import express from "express";
2
+ import { validateSessionAndRole, } from "mbkauthe";
3
+ import { listfiles, uploadFile, deleteFile, deleteFiles, deleteFolder, downloadFile, getFileMetadata, fileExists, ensureKeyHasAppPrefix, ensurePrefix, getAppName, createMultipartUpload, uploadPart, completeMultipartUpload, abortMultipartUpload, listIncompleteMultipartUploads, cleanupIncompleteMultipartUploads } from "../s3.js";
4
+ import multer from "multer";
5
+
6
+ const router = express.Router();
7
+ const upload = multer({ storage: multer.memoryStorage() });
8
+
9
+ // API endpoint for multipart upload cleanup
10
+ router.post('/api/cleanup-uploads', validateSessionAndRole('SuperAdmin'), async (req, res) => {
11
+ try {
12
+ const { olderThanDays = 7, prefix = '' } = req.body;
13
+
14
+ // Validate olderThanDays
15
+ const days = parseInt(olderThanDays, 10);
16
+ if (isNaN(days) || days < 1) {
17
+ return res.status(400).json({ success: false, error: 'olderThanDays must be a positive integer' });
18
+ }
19
+
20
+ console.log(`[mbkbucket] Starting cleanup of incomplete uploads older than ${days} days`);
21
+ const result = await cleanupIncompleteMultipartUploads(days, prefix);
22
+
23
+ res.json({
24
+ success: true,
25
+ message: `Cleaned up ${result.abortedCount} incomplete multipart upload(s)`,
26
+ ...result
27
+ });
28
+ } catch (error) {
29
+ console.error('[mbkbucket] Cleanup failed:', error);
30
+ res.status(500).json({
31
+ success: false,
32
+ error: error.message
33
+ });
34
+ }
35
+ });
36
+
37
+ // API endpoint to list incomplete multipart uploads
38
+ router.get('/api/incomplete-uploads', validateSessionAndRole('SuperAdmin'), async (req, res) => {
39
+ try {
40
+ const { prefix = '' } = req.query;
41
+ const uploads = await listIncompleteMultipartUploads(prefix);
42
+
43
+ res.json({
44
+ success: true,
45
+ count: uploads.length,
46
+ uploads: uploads.map(u => ({
47
+ key: u.Key,
48
+ uploadId: u.UploadId,
49
+ initiated: u.Initiated,
50
+ initiator: u.Initiator?.DisplayName
51
+ }))
52
+ });
53
+ } catch (error) {
54
+ console.error('[mbkbucket] Failed to list incomplete uploads:', error);
55
+ res.status(500).json({
56
+ success: false,
57
+ error: error.message
58
+ });
59
+ }
60
+ });
61
+
62
+ // API endpoint for fetching files
63
+ router.get('/api/files', validateSessionAndRole('SuperAdmin'), async (req, res) => {
64
+ const { prefix = '', page = '1', search = '', recursive = 'true', token = '' } = req.query;
65
+ const pageSize = 100;
66
+ let effectivePrefix;
67
+ try {
68
+ effectivePrefix = ensurePrefix(prefix);
69
+ } catch (e) {
70
+ return res.status(400).json({ success: false, error: e.message });
71
+ }
72
+
73
+ // Determine mode: Optimized (Folder View, No Search) vs Legacy/Deep (Flat View or Search)
74
+ const isSearch = search && search.trim().length > 0;
75
+ // If recursive is explicitly 'false', we respect it. Default to true if missing (though frontend should send it)
76
+ // Logic: Optimization works only if NOT recursive and NOT searching.
77
+ const isRecursive = recursive === 'true';
78
+ const useOptimizedListing = !isSearch && !isRecursive;
79
+
80
+ try {
81
+ let files = [];
82
+ let folders = []; // For CommonPrefixes (subfolders)
83
+ let nextContinuationToken = null;
84
+ let usedPrefix = effectivePrefix;
85
+ let legacyFallback = false;
86
+
87
+ if (useOptimizedListing) {
88
+ // --- OPTIMIZED PATH: S3 Delimiter (Folder View) ---
89
+ // We process only one page from S3. This reduces API calls significantly.
90
+
91
+ // FIX: Ensure root listing uses trailing slash to prevent "app-name" matching "app-name-backup"
92
+ // and ensure directory browsing works correctly if ensurePrefix didn't append slash (which it now does if present in input).
93
+ // But for root, input is empty, ensurePrefix returns "app", so we force "app/" here.
94
+ let listPrefix = effectivePrefix;
95
+ if (listPrefix === getAppName() && !listPrefix.endsWith('/')) {
96
+ listPrefix += '/';
97
+ }
98
+
99
+ const result = await listfiles(listPrefix, {
100
+ continuationToken: token || undefined, // Use token from query if provided
101
+ delimiter: '/',
102
+ maxKeys: 1000 // Fetch a healthy batch.
103
+ });
104
+
105
+ files = result.Contents || [];
106
+ // Filter out folder marker objects (zero-byte files ending with /)
107
+ files = files.filter(f => !f.Key.endsWith('/'));
108
+ // S3 returns CommonPrefixes for folders when delimiter is used
109
+ folders = (result.CommonPrefixes || []).map(p => p.Prefix).filter(p => p !== effectivePrefix);
110
+ nextContinuationToken = result.NextContinuationToken;
111
+
112
+ // Note: In optimized mode, totalFiles is unknown without scanning everything.
113
+ } else {
114
+ // --- LEGACY PATH: Recursive Scan (Search or Flat View) ---
115
+ let continuationToken = undefined;
116
+
117
+ // Fetch all files recursively (handling S3 pagination)
118
+ do {
119
+ const result = await listfiles(effectivePrefix, { continuationToken });
120
+ if (result.Contents) {
121
+ // Filter out folder marker objects (zero-byte files ending with /)
122
+ const actualFiles = result.Contents.filter(f => !f.Key.endsWith('/'));
123
+ files = files.concat(actualFiles);
124
+ }
125
+ continuationToken = result.nextToken;
126
+ } while (continuationToken);
127
+
128
+ // If no files found under the app prefixed path, try root as a legacy fallback
129
+ if (!files.length && !search) {
130
+ try {
131
+ const rootResult = await listfiles('');
132
+ if ((rootResult.Contents || []).length) {
133
+ files = rootResult.Contents || [];
134
+ usedPrefix = '';
135
+ legacyFallback = true;
136
+ }
137
+ } catch (fallbackErr) {
138
+ console.warn('Fallback root listing failed:', fallbackErr.message);
139
+ }
140
+ }
141
+
142
+ // Apply search filter if provided
143
+ if (isSearch) {
144
+ const searchLower = search.toLowerCase();
145
+ files = files.filter(file => {
146
+ return file.Key.toLowerCase().includes(searchLower);
147
+ });
148
+ }
149
+ }
150
+
151
+ // Response Construction
152
+ if (useOptimizedListing) {
153
+ res.json({
154
+ success: true,
155
+ files: files,
156
+ folders: folders,
157
+ prefix: effectivePrefix,
158
+ search: search,
159
+ // In optimized mode, we use token-based pagination, not page numbers
160
+ currentPage: 1,
161
+ totalPages: 1, // Always 1 since we use continuation tokens instead
162
+ hasNextPage: !!nextContinuationToken,
163
+ hasPrevPage: false, // Token-based pagination doesn't support backwards navigation
164
+ nextContinuationToken: nextContinuationToken,
165
+ mode: 'optimized',
166
+ totalFiles: -1, // Unknown in optimized mode without full scan
167
+ paginationType: 'continuation-token' // Indicate the type of pagination
168
+ });
169
+
170
+ } else {
171
+ // Legacy Pagination Logic (In-Memory)
172
+ const totalFiles = files.length;
173
+ const totalPages = Math.max(1, Math.ceil(totalFiles / pageSize));
174
+ const currentPage = Math.max(1, parseInt(page, 10) || 1);
175
+ const startIndex = (currentPage - 1) * pageSize;
176
+ const paginatedFiles = files.slice(startIndex, startIndex + pageSize);
177
+
178
+ res.json({
179
+ success: true,
180
+ files: paginatedFiles,
181
+ folders: [], // In recursive mode, frontend calculates folders from file paths
182
+ prefix: effectivePrefix,
183
+ search: search,
184
+ currentPage: currentPage,
185
+ totalPages,
186
+ hasNextPage: currentPage < totalPages,
187
+ hasPrevPage: currentPage > 1,
188
+ nextPage: currentPage + 1,
189
+ prevPage: Math.max(1, currentPage - 1),
190
+ totalFiles
191
+ });
192
+ }
193
+
194
+ } catch (error) {
195
+ console.error("Error listing files:", error);
196
+ res.status(500).json({
197
+ success: false,
198
+ error: "Error listing files: " + error.message,
199
+ files: [],
200
+ totalFiles: 0
201
+ });
202
+ }
203
+ });
204
+
205
+ // Standard single-file upload
206
+ router.post('/upload', validateSessionAndRole('SuperAdmin'), upload.single('file'), async (req, res) => {
207
+ try {
208
+ if (!req.file) {
209
+ return res.status(400).json({ success: false, error: 'No file selected' });
210
+ }
211
+
212
+ const { prefix = '' } = req.body;
213
+ let effectivePrefix;
214
+ try {
215
+ effectivePrefix = ensurePrefix(prefix);
216
+ } catch (e) {
217
+ return res.status(400).json({ success: false, error: e.message });
218
+ }
219
+
220
+ const key = `${effectivePrefix.replace(/\/+$/, '')}/${req.file.originalname}`;
221
+
222
+ const uploadOptions = {
223
+ metadata: {
224
+ 'original-name': req.file.originalname,
225
+ 'upload-source': 'web-portal',
226
+ 'user-agent': req.headers['user-agent'] || 'unknown'
227
+ },
228
+ preventOverwrite: true // Use atomic operation to prevent race conditions
229
+ };
230
+
231
+ try {
232
+ await uploadFile(key, req.file.buffer, req.file.mimetype, uploadOptions);
233
+
234
+ // Clean up folder marker if it exists in the parent directory
235
+ // This happens when a file is uploaded to a folder that was previously empty
236
+ const folderPath = key.substring(0, key.lastIndexOf('/') + 1);
237
+ if (folderPath) {
238
+ const folderMarkerKey = folderPath; // Already ends with /
239
+ try {
240
+ const markerExists = await fileExists(folderMarkerKey);
241
+ if (markerExists) {
242
+ // Check if this is actually a folder marker (zero-byte file)
243
+ const metadata = await getFileMetadata(folderMarkerKey);
244
+ if (metadata.ContentLength === 0 || metadata.Metadata?.marker === 'true') {
245
+ await deleteFile(folderMarkerKey);
246
+ console.log(`[mbkbucket] Cleaned up folder marker: ${folderMarkerKey}`);
247
+ }
248
+ }
249
+ } catch (cleanupErr) {
250
+ // Non-critical error, just log it
251
+ console.warn('[mbkbucket] Failed to clean up folder marker:', cleanupErr.message);
252
+ }
253
+ }
254
+
255
+ res.json({ success: true, message: 'File uploaded successfully', key: key });
256
+ } catch (uploadError) {
257
+ // Handle file already exists error from atomic operation
258
+ if (uploadError.message.includes('already exists')) {
259
+ return res.status(409).json({ success: false, error: `File already exists: ${req.file.originalname}` });
260
+ }
261
+ throw uploadError;
262
+ }
263
+ } catch (error) {
264
+ console.error("Error uploading file:", error);
265
+ res.status(500).json({ success: false, error: `Upload failed: ${error.message}` });
266
+ }
267
+ });
268
+
269
+ // S3 Multipart Upload — Step 1: Initiate
270
+ // Body: { fileName, prefix?, contentType? }
271
+ // Returns: { uploadId, key }
272
+ router.post('/upload-init', validateSessionAndRole('SuperAdmin'), async (req, res) => {
273
+ try {
274
+ const { fileName, prefix = '', contentType = 'application/octet-stream' } = req.body;
275
+ if (!fileName) return res.status(400).json({ success: false, error: 'fileName is required' });
276
+
277
+ let effectivePrefix;
278
+ try { effectivePrefix = ensurePrefix(prefix); } catch (e) { return res.status(400).json({ success: false, error: e.message }); }
279
+ const key = `${effectivePrefix.replace(/\/+$/, '')}/${fileName}`;
280
+
281
+ // Check if file exists before initiating multipart upload
282
+ // Note: This is still slightly racy, but multipart uploads take time so collision risk is lower
283
+ // A complete solution would require server-side locking or unique upload IDs
284
+ const exists = await fileExists(key);
285
+ if (exists) return res.status(409).json({ success: false, error: `File already exists: ${fileName}` });
286
+
287
+ const result = await createMultipartUpload(key, contentType);
288
+ return res.json({ success: true, uploadId: result.uploadId, key: result.key });
289
+ } catch (err) {
290
+ console.error('[mbkbucket] upload-init failed:', err);
291
+ return res.status(500).json({ success: false, error: err.message });
292
+ }
293
+ });
294
+
295
+ // S3 Multipart Upload — Step 2: Upload a part
296
+ // FormData: chunk (file), partNumber (1-based integer), uploadId, key
297
+ // Returns: { partNumber, ETag }
298
+ router.post('/upload-chunk', validateSessionAndRole('SuperAdmin'), upload.single('chunk'), async (req, res) => {
299
+ try {
300
+ const { uploadId, key, partNumber } = req.body;
301
+ if (!req.file || !uploadId || !key || !partNumber) {
302
+ return res.status(400).json({ success: false, error: 'chunk file, uploadId, key, and partNumber are required' });
303
+ }
304
+ const part = parseInt(partNumber, 10);
305
+ if (isNaN(part) || part < 1 || part > 10000) {
306
+ return res.status(400).json({ success: false, error: 'partNumber must be an integer between 1 and 10000' });
307
+ }
308
+
309
+ const result = await uploadPart(key, uploadId, part, req.file.buffer);
310
+ return res.json({ success: true, partNumber: result.partNumber, ETag: result.ETag });
311
+ } catch (err) {
312
+ console.error('[mbkbucket] upload-chunk failed:', err);
313
+ return res.status(500).json({ success: false, error: err.message });
314
+ }
315
+ });
316
+
317
+ // S3 Multipart Upload — Step 3: Complete
318
+ // Body: { uploadId, key, parts: [{ partNumber, ETag }] }
319
+ // Returns: { key }
320
+ router.post('/upload-complete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
321
+ try {
322
+ const { uploadId, key, parts } = req.body;
323
+ if (!uploadId || !key || !Array.isArray(parts) || !parts.length) {
324
+ return res.status(400).json({ success: false, error: 'uploadId, key, and parts array are required' });
325
+ }
326
+
327
+ const result = await completeMultipartUpload(key, uploadId, parts);
328
+
329
+ // Clean up folder marker if it exists in the parent directory
330
+ const folderPath = result.key.substring(0, result.key.lastIndexOf('/') + 1);
331
+ if (folderPath) {
332
+ const folderMarkerKey = folderPath;
333
+ try {
334
+ const markerExists = await fileExists(folderMarkerKey);
335
+ if (markerExists) {
336
+ const metadata = await getFileMetadata(folderMarkerKey);
337
+ if (metadata.ContentLength === 0 || metadata.Metadata?.marker === 'true') {
338
+ await deleteFile(folderMarkerKey);
339
+ console.log(`[mbkbucket] Cleaned up folder marker: ${folderMarkerKey}`);
340
+ }
341
+ }
342
+ } catch (cleanupErr) {
343
+ console.warn('[mbkbucket] Failed to clean up folder marker:', cleanupErr.message);
344
+ }
345
+ }
346
+
347
+ return res.json({ success: true, message: 'File uploaded successfully', key: result.key });
348
+ } catch (err) {
349
+ console.error('[mbkbucket] upload-complete failed:', err);
350
+ return res.status(500).json({ success: false, error: err.message });
351
+ }
352
+ });
353
+
354
+ // S3 Multipart Upload — Abort (cleanup on client cancel/error)
355
+ // Body: { uploadId, key }
356
+ router.post('/upload-abort', validateSessionAndRole('SuperAdmin'), async (req, res) => {
357
+ try {
358
+ const { uploadId, key } = req.body;
359
+ if (!uploadId || !key) return res.status(400).json({ success: false, error: 'uploadId and key are required' });
360
+
361
+ await abortMultipartUpload(key, uploadId);
362
+ return res.json({ success: true, message: 'Multipart upload aborted' });
363
+ } catch (err) {
364
+ console.error('[mbkbucket] upload-abort failed:', err);
365
+ return res.status(500).json({ success: false, error: err.message });
366
+ }
367
+ });
368
+
369
+ // Create folder (zero-byte object with trailing slash)
370
+ router.post('/create-folder', validateSessionAndRole('SuperAdmin'), async (req, res) => {
371
+ try {
372
+ const { prefix = '', folderName } = req.body;
373
+ if (!folderName) return res.status(400).json({ success: false, error: 'folderName is required' });
374
+
375
+ let effectivePrefix;
376
+ try { effectivePrefix = ensurePrefix(prefix); } catch (e) { return res.status(400).json({ success: false, error: e.message }); }
377
+
378
+ const folderKey = `${effectivePrefix.replace(/\/+$/, '')}/${folderName}/`;
379
+
380
+ // Check if folder marker already exists
381
+ const markerExists = await fileExists(folderKey);
382
+ if (markerExists) return res.status(409).json({ success: false, error: 'Folder already exists' });
383
+
384
+ // Check if any files already exist under this prefix (folder already has content)
385
+ const existingFiles = await listfiles(folderKey, { maxKeys: 1 });
386
+ const hasContent = existingFiles.Contents && existingFiles.Contents.length > 0;
387
+
388
+ if (hasContent) {
389
+ // Folder already exists with content, no need to create marker
390
+ return res.json({
391
+ success: true,
392
+ message: 'Folder already exists with content',
393
+ key: folderKey,
394
+ skipMarker: true
395
+ });
396
+ }
397
+
398
+ // Create zero-byte object to represent empty folder
399
+ await uploadFile(folderKey, Buffer.alloc(0), 'application/x-empty', {
400
+ metadata: { 'folder': 'true', 'marker': 'true' }
401
+ });
402
+
403
+ return res.json({ success: true, message: 'Folder created', key: folderKey });
404
+ } catch (err) {
405
+ console.error('Create folder failed:', err);
406
+ return res.status(500).json({ success: false, error: err.message });
407
+ }
408
+ });
409
+
410
+ router.post('/delete', validateSessionAndRole('SuperAdmin'), async (req, res) => {
411
+ try {
412
+ const { key, keys, folder } = req.body;
413
+
414
+ if (keys && Array.isArray(keys) && keys.length) {
415
+ // Bulk delete
416
+ await deleteFiles(keys);
417
+ return res.json({ success: true, message: 'Files deleted successfully' });
418
+ }
419
+
420
+ if (!key) return res.status(400).json({ success: false, error: 'Key is required' });
421
+
422
+ let keyToDelete;
423
+ try { keyToDelete = ensureKeyHasAppPrefix(key); } catch (e) { return res.status(400).json({ success: false, error: e.message }); }
424
+
425
+ // If caller indicates it's a folder (or key ends with '/'), delete all objects under the prefix
426
+ if (folder === true || String(keyToDelete).endsWith('/')) {
427
+ await deleteFolder(keyToDelete);
428
+ return res.json({ success: true, message: 'Folder deleted successfully' });
429
+ }
430
+
431
+ await deleteFile(keyToDelete);
432
+ res.json({ success: true, message: 'File deleted successfully' });
433
+ } catch (error) {
434
+ console.error("Error deleting file:", error);
435
+ res.status(500).json({ success: false, error: `Delete failed: ${error.message}` });
436
+ }
437
+ });
438
+
439
+ router.get('/download/:key(*)', validateSessionAndRole('SuperAdmin'), async (req, res) => {
440
+ let stream = null;
441
+ try {
442
+ const key = req.params.key;
443
+ let keyToUse;
444
+ try {
445
+ keyToUse = ensureKeyHasAppPrefix(key);
446
+ } catch (e) {
447
+ return res.status(400).json({ success: false, error: e.message });
448
+ }
449
+ const result = await downloadFile(keyToUse);
450
+ stream = result.Body;
451
+
452
+ res.setHeader('Content-Disposition', `attachment; filename="${keyToUse.split('/').pop()}"`);
453
+ res.setHeader('Content-Type', result.ContentType || 'application/octet-stream');
454
+
455
+ // Set Content-Length header for proper download progress indication
456
+ if (result.ContentLength) {
457
+ res.setHeader('Content-Length', result.ContentLength);
458
+ }
459
+
460
+ // Add cache headers for downloads
461
+ res.setHeader('Cache-Control', 'private, max-age=3600');
462
+
463
+ // Proper stream error handling
464
+ stream.on('error', (err) => {
465
+ console.error('Download stream error:', err);
466
+ if (!res.headersSent) {
467
+ res.status(500).json({ message: 'Error streaming file', error: err.message });
468
+ }
469
+ if (stream && typeof stream.destroy === 'function') stream.destroy();
470
+ });
471
+
472
+ // Clean up on client disconnect
473
+ req.on('close', () => {
474
+ if (stream && typeof stream.destroy === 'function') stream.destroy();
475
+ });
476
+
477
+ // Handle request timeout
478
+ const timeoutDuration = result.ContentLength > 50 * 1024 * 1024 ? 300000 : 120000;
479
+ req.setTimeout(timeoutDuration, () => {
480
+ console.error(`Download timeout (${timeoutDuration}ms) for: ${keyToUse}`);
481
+ if (stream && typeof stream.destroy === 'function') stream.destroy();
482
+ });
483
+
484
+ stream.pipe(res);
485
+ }
486
+ catch (error) {
487
+ console.error("Error downloading file:", error);
488
+
489
+ // Clean up stream on error
490
+ if (stream && typeof stream.destroy === 'function') stream.destroy();
491
+ if (error.message.includes('File not found')) {
492
+ res.status(404).json({ message: "File not found", key: req.params.key });
493
+ } else if (error.message.includes('Access denied')) {
494
+ res.status(403).json({ message: "Access denied" });
495
+ } else {
496
+ res.status(500).json({ message: "Download failed", error: error.message });
497
+ }
498
+ }
499
+ });
500
+
501
+ export default router;
@@ -0,0 +1,17 @@
1
+ import express from "express";
2
+ import infoRoutes from "./info.js";
3
+ import viewRoutes from "./view.js";
4
+ import apiRoutes from "./api.js";
5
+ import { validateSessionAndRole, renderPage, mbkautheVar } from "mbkauthe";
6
+
7
+ const router = express.Router();
8
+
9
+ router.get('/mbkbucket', validateSessionAndRole('SuperAdmin'), async (req, res) => {
10
+ renderPage(req, res, "bucket.handlebars", false, { page: "Admin Bucket", bucketvar: mbkautheVar?.bucket, APP_NAME: mbkautheVar?.APP_NAME, message: req.query.message, error: req.query.error });
11
+ });
12
+
13
+ router.use("/mbkbucket", infoRoutes);
14
+ router.use("/mbkbucket", viewRoutes);
15
+ router.use("/mbkbucket", apiRoutes);
16
+
17
+ export default router;