mbkbucket 3.3.1 → 3.4.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/README.md +17 -24
- package/index.js +2 -12
- package/lib/cli.js +191 -525
- package/lib/public/bucketadmin.js +1 -1
- package/lib/src/app.js +37 -50
- package/lib/src/cli/auth-helper.js +39 -112
- package/lib/src/config/index.js +68 -96
- package/lib/src/config/validation.js +14 -30
- package/lib/src/controllers/api.controller.js +85 -176
- package/lib/src/controllers/dashboard.controller.js +12 -23
- package/lib/src/controllers/info.controller.js +17 -20
- package/lib/src/controllers/view.controller.js +23 -70
- package/lib/src/middleware/async-handler.js +1 -16
- package/lib/src/middleware/bucket-resolver.js +7 -13
- package/lib/src/middleware/error-handler.js +40 -20
- package/lib/src/middleware/rate-limiter.js +2 -10
- package/lib/src/middleware/security.js +1 -17
- package/lib/src/routes/api.routes.js +21 -47
- package/lib/src/routes/index.js +2 -10
- package/lib/src/routes/info.routes.js +2 -3
- package/lib/src/routes/view.routes.js +3 -8
- package/lib/src/services/s3.service.js +157 -349
- package/lib/src/utils/errors.js +37 -59
- package/lib/src/utils/helpers.js +124 -70
- package/package.json +13 -5
- /package/{lib/views → views}/bucket.handlebars +0 -0
- /package/{lib/views → views}/bucketadmin_alerts.handlebars +0 -0
- /package/{lib/views → views}/bucketadmin_delete_modal.handlebars +0 -0
- /package/{lib/views → views}/bucketadmin_filelist_skeleton.handlebars +0 -0
- /package/{lib/views → views}/bucketadmin_newfolder_modal.handlebars +0 -0
- /package/{lib/views → views}/bucketadmin_preview_modal.handlebars +0 -0
- /package/{lib/views → views}/bucketadmin_upload_modal.handlebars +0 -0
- /package/{lib/views → views}/bucketadmincontent.handlebars +0 -0
- /package/{lib/views → views}/bucketportal.handlebars +0 -0
- /package/{lib/views → views}/mbkbucket_info.handlebars +0 -0
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
const TRUE_VALUES = new Set(['true', 't', '1', 'yes', 'y', 'on']);
|
|
2
2
|
const FALSE_VALUES = new Set(['false', 'f', '0', 'no', 'n', 'off']);
|
|
3
3
|
|
|
4
|
-
function normalizeBooleanLike(value) {
|
|
4
|
+
export function normalizeBooleanLike(value) {
|
|
5
5
|
if (typeof value === 'boolean') return value;
|
|
6
6
|
if (typeof value === 'string') {
|
|
7
|
-
const
|
|
8
|
-
if (TRUE_VALUES.has(
|
|
9
|
-
if (FALSE_VALUES.has(
|
|
7
|
+
const norm = value.trim().toLowerCase();
|
|
8
|
+
if (TRUE_VALUES.has(norm)) return true;
|
|
9
|
+
if (FALSE_VALUES.has(norm)) return false;
|
|
10
10
|
}
|
|
11
11
|
return value;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function parseAndValidateMbkbucketVar(rawValue) {
|
|
15
|
-
const errors = [];
|
|
16
15
|
let parsed;
|
|
17
|
-
|
|
18
16
|
try {
|
|
19
17
|
parsed = rawValue ? JSON.parse(rawValue) : {};
|
|
20
18
|
} catch {
|
|
@@ -25,14 +23,10 @@ export function parseAndValidateMbkbucketVar(rawValue) {
|
|
|
25
23
|
throw new Error('mbkbucketVar must be a valid object');
|
|
26
24
|
}
|
|
27
25
|
|
|
28
|
-
const defaults = {
|
|
29
|
-
p_view_inline: true,
|
|
30
|
-
publiView_enabled: false
|
|
31
|
-
};
|
|
32
|
-
|
|
26
|
+
const defaults = { p_view_inline: true, publiView_enabled: false };
|
|
33
27
|
for (const [key, defaultVal] of Object.entries(defaults)) {
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
28
|
+
const val = parsed[key];
|
|
29
|
+
if (val === undefined || (typeof val === 'string' && val.trim() === '')) {
|
|
36
30
|
parsed[key] = defaultVal;
|
|
37
31
|
}
|
|
38
32
|
}
|
|
@@ -40,24 +34,16 @@ export function parseAndValidateMbkbucketVar(rawValue) {
|
|
|
40
34
|
parsed.p_view_inline = normalizeBooleanLike(parsed.p_view_inline);
|
|
41
35
|
parsed.publiView_enabled = normalizeBooleanLike(parsed.publiView_enabled);
|
|
42
36
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
errors.push('mbkbucketVar.publiView_enabled must be a boolean if provided');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (errors.length) {
|
|
51
|
-
throw new Error(errors.join(' | '));
|
|
52
|
-
}
|
|
37
|
+
const errors = [];
|
|
38
|
+
if (typeof parsed.p_view_inline !== 'boolean') errors.push('mbkbucketVar.p_view_inline must be a boolean if provided');
|
|
39
|
+
if (typeof parsed.publiView_enabled !== 'boolean') errors.push('mbkbucketVar.publiView_enabled must be a boolean if provided');
|
|
40
|
+
if (errors.length) throw new Error(errors.join(' | '));
|
|
53
41
|
|
|
54
42
|
return parsed;
|
|
55
43
|
}
|
|
56
44
|
|
|
57
45
|
export function parseAndValidateBucketConnection(rawValue) {
|
|
58
|
-
if (!rawValue)
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
46
|
+
if (!rawValue) return null;
|
|
61
47
|
|
|
62
48
|
let parsed;
|
|
63
49
|
try {
|
|
@@ -66,7 +52,7 @@ export function parseAndValidateBucketConnection(rawValue) {
|
|
|
66
52
|
throw new Error('BucketConnection is not valid JSON');
|
|
67
53
|
}
|
|
68
54
|
|
|
69
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.keys(parsed).length
|
|
55
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !Object.keys(parsed).length) {
|
|
70
56
|
throw new Error('BucketConnection must be a non-empty object');
|
|
71
57
|
}
|
|
72
58
|
|
|
@@ -77,7 +63,7 @@ export function parseAndValidateBucketConnection(rawValue) {
|
|
|
77
63
|
throw new Error(`Bucket '${bucketName}' configuration must be an object, not a string. Remove quotes around the inner JSON object.`);
|
|
78
64
|
}
|
|
79
65
|
|
|
80
|
-
const missingFields = requiredFields.filter(
|
|
66
|
+
const missingFields = requiredFields.filter(field => !config[field]);
|
|
81
67
|
if (missingFields.length) {
|
|
82
68
|
throw new Error(`Bucket '${bucketName}' is missing required fields: ${missingFields.join(', ')}`);
|
|
83
69
|
}
|
|
@@ -85,5 +71,3 @@ export function parseAndValidateBucketConnection(rawValue) {
|
|
|
85
71
|
|
|
86
72
|
return parsed;
|
|
87
73
|
}
|
|
88
|
-
|
|
89
|
-
export { normalizeBooleanLike };
|
|
@@ -1,26 +1,11 @@
|
|
|
1
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";
|
|
2
|
+
import { listfiles, uploadFile, deleteFile, deleteFiles, deleteFolder, downloadFile, getFileMetadata, fileExists, ensureKeyHasAppPrefix, ensurePrefix, createMultipartUpload, uploadPart, completeMultipartUpload, abortMultipartUpload, listIncompleteMultipartUploads, cleanupIncompleteMultipartUploads } from "../services/s3.service.js";
|
|
10
3
|
import { sendApiError, classifyApiError } from "../utils/errors.js";
|
|
11
|
-
import { getBaseName } from "../utils/helpers.js";
|
|
12
|
-
import { createLogger } from "
|
|
4
|
+
import { getBaseName, isSensitiveTextType, buildKey, normalizeKeyParam, getFolderPath, destroyStream } from "../utils/helpers.js";
|
|
5
|
+
import { createLogger } from "#logger";
|
|
13
6
|
|
|
14
7
|
const debugApi = createLogger('api');
|
|
15
8
|
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
// Helpers
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
|
|
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
9
|
function resolvePrefix(req, res, prefix) {
|
|
25
10
|
try {
|
|
26
11
|
return ensurePrefix(prefix);
|
|
@@ -30,45 +15,29 @@ function resolvePrefix(req, res, prefix) {
|
|
|
30
15
|
}
|
|
31
16
|
}
|
|
32
17
|
|
|
33
|
-
/**
|
|
34
|
-
* Build a full S3 key from a prefix and filename, stripping trailing slashes.
|
|
35
|
-
*/
|
|
36
|
-
function buildKey(prefix, fileName) {
|
|
37
|
-
const cleanPrefix = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
38
|
-
const cleanName = String(fileName || '').replace(/^\/+/, '');
|
|
39
|
-
return cleanPrefix ? `${cleanPrefix}/${cleanName}` : cleanName;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
18
|
async function cleanupFolderMarker(folderPath, bucketName) {
|
|
43
19
|
if (!folderPath) return;
|
|
44
|
-
|
|
45
20
|
try {
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
21
|
+
const meta = await getFileMetadata(folderPath, bucketName);
|
|
22
|
+
if (meta.exists && (meta.ContentLength === 0 || meta.Metadata?.marker === 'true')) {
|
|
48
23
|
await deleteFile(folderPath, bucketName);
|
|
49
24
|
debugApi('Cleaned up folder marker: %s', folderPath);
|
|
50
25
|
}
|
|
51
|
-
} catch (
|
|
52
|
-
debugApi('Failed to clean up folder marker: %s',
|
|
26
|
+
} catch (err) {
|
|
27
|
+
debugApi('Failed to clean up folder marker: %s', err.message);
|
|
53
28
|
}
|
|
54
29
|
}
|
|
55
30
|
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// Controller handlers
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
|
|
60
31
|
export async function cleanupUploads(req, res) {
|
|
61
32
|
try {
|
|
62
33
|
const { olderThanDays = 7, prefix = '' } = req.body;
|
|
63
|
-
const bucketName = req.activeBucket;
|
|
64
|
-
|
|
65
34
|
const days = parseInt(olderThanDays, 10);
|
|
66
35
|
if (isNaN(days) || days < 1) {
|
|
67
36
|
return res.status(400).json({ success: false, error: 'olderThanDays must be a positive integer' });
|
|
68
37
|
}
|
|
69
38
|
|
|
70
39
|
debugApi('Starting cleanup of incomplete uploads older than %s days', days);
|
|
71
|
-
const result = await cleanupIncompleteMultipartUploads(days, prefix,
|
|
40
|
+
const result = await cleanupIncompleteMultipartUploads(days, prefix, req.activeBucket);
|
|
72
41
|
|
|
73
42
|
res.json({
|
|
74
43
|
success: true,
|
|
@@ -84,9 +53,7 @@ export async function cleanupUploads(req, res) {
|
|
|
84
53
|
export async function listIncompleteUploads(req, res) {
|
|
85
54
|
try {
|
|
86
55
|
const { prefix = '' } = req.query;
|
|
87
|
-
const
|
|
88
|
-
const uploads = await listIncompleteMultipartUploads(prefix, bucketName);
|
|
89
|
-
|
|
56
|
+
const uploads = await listIncompleteMultipartUploads(prefix, req.activeBucket);
|
|
90
57
|
res.json({
|
|
91
58
|
success: true,
|
|
92
59
|
count: uploads.length,
|
|
@@ -111,7 +78,7 @@ export async function listFiles(req, res) {
|
|
|
111
78
|
const effectivePrefix = resolvePrefix(req, res, prefix);
|
|
112
79
|
if (effectivePrefix === null) return;
|
|
113
80
|
|
|
114
|
-
const isSearch = search && search.trim()
|
|
81
|
+
const isSearch = Boolean(search && search.trim());
|
|
115
82
|
const isRecursive = recursive === 'true';
|
|
116
83
|
const useOptimizedListing = !isSearch && !isRecursive;
|
|
117
84
|
|
|
@@ -119,14 +86,10 @@ export async function listFiles(req, res) {
|
|
|
119
86
|
let files = [];
|
|
120
87
|
let folders = [];
|
|
121
88
|
let nextContinuationToken = null;
|
|
122
|
-
let usedPrefix = effectivePrefix;
|
|
123
|
-
let legacyFallback = false;
|
|
124
89
|
|
|
125
90
|
if (useOptimizedListing) {
|
|
126
91
|
let listPrefix = effectivePrefix ? String(effectivePrefix).replace(/^\/+/, '') : '';
|
|
127
|
-
if (listPrefix && !listPrefix.endsWith('/'))
|
|
128
|
-
listPrefix += '/';
|
|
129
|
-
}
|
|
92
|
+
if (listPrefix && !listPrefix.endsWith('/')) listPrefix += '/';
|
|
130
93
|
|
|
131
94
|
const result = await listfiles(listPrefix, {
|
|
132
95
|
continuationToken: token || undefined,
|
|
@@ -140,42 +103,8 @@ export async function listFiles(req, res) {
|
|
|
140
103
|
.filter(p => p.Prefix !== listPrefix && p.Prefix !== effectivePrefix)
|
|
141
104
|
.map(p => p.Prefix);
|
|
142
105
|
nextContinuationToken = result.NextContinuationToken;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
else {
|
|
146
|
-
let continuationToken = undefined;
|
|
147
|
-
|
|
148
|
-
do {
|
|
149
|
-
const result = await listfiles(effectivePrefix, { continuationToken, bucketName });
|
|
150
|
-
if (result.Contents?.length) {
|
|
151
|
-
for (const file of result.Contents) {
|
|
152
|
-
if (!file.Key.endsWith('/')) files.push(file);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
continuationToken = result.nextToken;
|
|
156
|
-
} while (continuationToken);
|
|
157
|
-
|
|
158
|
-
if (!files.length && !search) {
|
|
159
|
-
try {
|
|
160
|
-
const rootResult = await listfiles('', { bucketName });
|
|
161
|
-
if ((rootResult.Contents || []).length) {
|
|
162
|
-
files = rootResult.Contents || [];
|
|
163
|
-
usedPrefix = '';
|
|
164
|
-
legacyFallback = true;
|
|
165
|
-
}
|
|
166
|
-
} catch (fallbackErr) {
|
|
167
|
-
debugApi('Fallback root listing failed: %s', fallbackErr.message);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
106
|
|
|
171
|
-
|
|
172
|
-
const searchLower = search.trim().toLowerCase();
|
|
173
|
-
files = files.filter(file => file.Key.toLowerCase().includes(searchLower));
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (useOptimizedListing) {
|
|
178
|
-
res.json({
|
|
107
|
+
return res.json({
|
|
179
108
|
success: true,
|
|
180
109
|
files,
|
|
181
110
|
folders,
|
|
@@ -190,29 +119,50 @@ export async function listFiles(req, res) {
|
|
|
190
119
|
totalFiles: -1,
|
|
191
120
|
paginationType: 'continuation-token'
|
|
192
121
|
});
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
res.json({
|
|
201
|
-
success: true,
|
|
202
|
-
files: paginatedFiles,
|
|
203
|
-
folders: [],
|
|
204
|
-
prefix: effectivePrefix,
|
|
205
|
-
search,
|
|
206
|
-
currentPage,
|
|
207
|
-
totalPages,
|
|
208
|
-
hasNextPage: currentPage < totalPages,
|
|
209
|
-
hasPrevPage: currentPage > 1,
|
|
210
|
-
nextPage: currentPage + 1,
|
|
211
|
-
prevPage: Math.max(1, currentPage - 1),
|
|
212
|
-
totalFiles
|
|
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);
|
|
213
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
|
+
}
|
|
214
140
|
}
|
|
215
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
|
+
});
|
|
216
166
|
} catch (error) {
|
|
217
167
|
console.error("Error listing files:", error);
|
|
218
168
|
const mapped = classifyApiError(error, 'Error listing files');
|
|
@@ -229,16 +179,12 @@ export async function listFiles(req, res) {
|
|
|
229
179
|
export async function uploadSingleFile(req, res) {
|
|
230
180
|
try {
|
|
231
181
|
const bucketName = req.activeBucket;
|
|
232
|
-
if (!req.file) {
|
|
233
|
-
return res.status(400).json({ success: false, error: 'No file selected' });
|
|
234
|
-
}
|
|
182
|
+
if (!req.file) return res.status(400).json({ success: false, error: 'No file selected' });
|
|
235
183
|
|
|
236
|
-
const
|
|
237
|
-
const effectivePrefix = resolvePrefix(req, res, prefix);
|
|
184
|
+
const effectivePrefix = resolvePrefix(req, res, req.body.prefix || '');
|
|
238
185
|
if (effectivePrefix === null) return;
|
|
239
186
|
|
|
240
187
|
const key = buildKey(effectivePrefix, req.file.originalname);
|
|
241
|
-
|
|
242
188
|
const uploadOptions = {
|
|
243
189
|
metadata: {
|
|
244
190
|
'original-name': req.file.originalname,
|
|
@@ -251,10 +197,7 @@ export async function uploadSingleFile(req, res) {
|
|
|
251
197
|
|
|
252
198
|
try {
|
|
253
199
|
await uploadFile(key, req.file.buffer, req.file.mimetype, uploadOptions);
|
|
254
|
-
|
|
255
|
-
const folderPath = key.substring(0, key.lastIndexOf('/') + 1);
|
|
256
|
-
await cleanupFolderMarker(folderPath, bucketName);
|
|
257
|
-
|
|
200
|
+
await cleanupFolderMarker(getFolderPath(key), bucketName);
|
|
258
201
|
res.json({ success: true, message: 'File uploaded successfully', key });
|
|
259
202
|
} catch (uploadError) {
|
|
260
203
|
if (uploadError.message.includes('already exists')) {
|
|
@@ -278,9 +221,9 @@ export async function initiateMultipartUpload(req, res) {
|
|
|
278
221
|
if (effectivePrefix === null) return;
|
|
279
222
|
|
|
280
223
|
const key = buildKey(effectivePrefix, fileName);
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
224
|
+
if (await fileExists(key, bucketName)) {
|
|
225
|
+
return res.status(409).json({ success: false, error: `File already exists: ${fileName}` });
|
|
226
|
+
}
|
|
284
227
|
|
|
285
228
|
const result = await createMultipartUpload(key, contentType, {}, bucketName);
|
|
286
229
|
return res.json({ success: true, uploadId: result.uploadId, key: result.key });
|
|
@@ -292,7 +235,6 @@ export async function initiateMultipartUpload(req, res) {
|
|
|
292
235
|
|
|
293
236
|
export async function uploadChunk(req, res) {
|
|
294
237
|
try {
|
|
295
|
-
const bucketName = req.activeBucket;
|
|
296
238
|
const { uploadId, key, partNumber } = req.body;
|
|
297
239
|
if (!req.file || !uploadId || !key || !partNumber) {
|
|
298
240
|
return res.status(400).json({ success: false, error: 'chunk file, uploadId, key, and partNumber are required' });
|
|
@@ -302,7 +244,7 @@ export async function uploadChunk(req, res) {
|
|
|
302
244
|
return res.status(400).json({ success: false, error: 'partNumber must be an integer between 1 and 10000' });
|
|
303
245
|
}
|
|
304
246
|
|
|
305
|
-
const result = await uploadPart(key, uploadId, part, req.file.buffer,
|
|
247
|
+
const result = await uploadPart(key, uploadId, part, req.file.buffer, req.activeBucket);
|
|
306
248
|
return res.json({ success: true, partNumber: result.partNumber, ETag: result.ETag });
|
|
307
249
|
} catch (err) {
|
|
308
250
|
console.error('[mbkbucket] upload-chunk failed:', err);
|
|
@@ -319,9 +261,7 @@ export async function completeUpload(req, res) {
|
|
|
319
261
|
}
|
|
320
262
|
|
|
321
263
|
const result = await completeMultipartUpload(key, uploadId, parts, bucketName);
|
|
322
|
-
|
|
323
|
-
const folderPath = result.key.substring(0, result.key.lastIndexOf('/') + 1);
|
|
324
|
-
await cleanupFolderMarker(folderPath, bucketName);
|
|
264
|
+
await cleanupFolderMarker(getFolderPath(result.key), bucketName);
|
|
325
265
|
|
|
326
266
|
return res.json({ success: true, message: 'File uploaded successfully', key: result.key });
|
|
327
267
|
} catch (err) {
|
|
@@ -332,11 +272,10 @@ export async function completeUpload(req, res) {
|
|
|
332
272
|
|
|
333
273
|
export async function abortUpload(req, res) {
|
|
334
274
|
try {
|
|
335
|
-
const bucketName = req.activeBucket;
|
|
336
275
|
const { uploadId, key } = req.body;
|
|
337
276
|
if (!uploadId || !key) return res.status(400).json({ success: false, error: 'uploadId and key are required' });
|
|
338
277
|
|
|
339
|
-
await abortMultipartUpload(key, uploadId,
|
|
278
|
+
await abortMultipartUpload(key, uploadId, req.activeBucket);
|
|
340
279
|
return res.json({ success: true, message: 'Multipart upload aborted' });
|
|
341
280
|
} catch (err) {
|
|
342
281
|
console.error('[mbkbucket] upload-abort failed:', err);
|
|
@@ -354,31 +293,22 @@ export async function createFolder(req, res) {
|
|
|
354
293
|
if (effectivePrefix === null) return;
|
|
355
294
|
|
|
356
295
|
const cleanName = String(folderName).replace(/^\/+/, '').replace(/\/+$/, '');
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
} else {
|
|
362
|
-
folderKey = `${cleanName}/`;
|
|
363
|
-
}
|
|
296
|
+
const cleanPrefix = effectivePrefix.replace(/\/+$/, '');
|
|
297
|
+
const folderKey = (effectivePrefix && !cleanName.startsWith(effectivePrefix))
|
|
298
|
+
? `${cleanPrefix}/${cleanName}/`
|
|
299
|
+
: `${cleanName}/`;
|
|
364
300
|
|
|
365
|
-
|
|
366
|
-
|
|
301
|
+
if (await fileExists(folderKey, bucketName)) {
|
|
302
|
+
return res.status(409).json({ success: false, error: 'Folder already exists' });
|
|
303
|
+
}
|
|
367
304
|
|
|
368
305
|
const existingFiles = await listfiles(folderKey, { maxKeys: 1, bucketName });
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (hasContent) {
|
|
372
|
-
return res.json({
|
|
373
|
-
success: true,
|
|
374
|
-
message: 'Folder already exists with content',
|
|
375
|
-
key: folderKey,
|
|
376
|
-
skipMarker: true
|
|
377
|
-
});
|
|
306
|
+
if (existingFiles.Contents?.length) {
|
|
307
|
+
return res.json({ success: true, message: 'Folder already exists with content', key: folderKey, skipMarker: true });
|
|
378
308
|
}
|
|
379
309
|
|
|
380
310
|
await uploadFile(folderKey, Buffer.alloc(0), 'application/x-empty', {
|
|
381
|
-
metadata: {
|
|
311
|
+
metadata: { folder: 'true', marker: 'true' },
|
|
382
312
|
bucketName
|
|
383
313
|
});
|
|
384
314
|
|
|
@@ -402,7 +332,11 @@ export async function deleteItems(req, res) {
|
|
|
402
332
|
if (!key) return res.status(400).json({ success: false, error: 'Key is required' });
|
|
403
333
|
|
|
404
334
|
let keyToDelete;
|
|
405
|
-
try {
|
|
335
|
+
try {
|
|
336
|
+
keyToDelete = ensureKeyHasAppPrefix(key);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
return res.status(400).json({ success: false, error: e.message });
|
|
339
|
+
}
|
|
406
340
|
|
|
407
341
|
if (folder === true || String(keyToDelete).endsWith('/')) {
|
|
408
342
|
await deleteFolder(keyToDelete, bucketName);
|
|
@@ -418,15 +352,12 @@ export async function deleteItems(req, res) {
|
|
|
418
352
|
}
|
|
419
353
|
|
|
420
354
|
export async function downloadFileHandler(req, res) {
|
|
421
|
-
// Track whether this request has been aborted (client disconnected)
|
|
422
355
|
let aborted = false;
|
|
423
356
|
req.once('aborted', () => { aborted = true; });
|
|
424
357
|
req.once('close', () => { aborted = true; });
|
|
425
358
|
|
|
426
359
|
try {
|
|
427
|
-
const
|
|
428
|
-
const rawKey = req.params.key;
|
|
429
|
-
const key = Array.isArray(rawKey) ? rawKey.join('/') : (rawKey || '');
|
|
360
|
+
const key = normalizeKeyParam(req.params.key);
|
|
430
361
|
let keyToUse;
|
|
431
362
|
try {
|
|
432
363
|
keyToUse = ensureKeyHasAppPrefix(key);
|
|
@@ -434,14 +365,11 @@ export async function downloadFileHandler(req, res) {
|
|
|
434
365
|
return res.status(400).json({ success: false, error: e.message });
|
|
435
366
|
}
|
|
436
367
|
|
|
437
|
-
// If client already disconnected before we fetched, bail early
|
|
438
368
|
if (aborted) return;
|
|
439
369
|
|
|
440
|
-
const result = await downloadFile(keyToUse, { bucketName });
|
|
441
|
-
|
|
442
|
-
// Re-check: client may have disconnected while waiting for S3
|
|
370
|
+
const result = await downloadFile(keyToUse, { bucketName: req.activeBucket });
|
|
443
371
|
if (aborted) {
|
|
444
|
-
|
|
372
|
+
destroyStream(result.Body);
|
|
445
373
|
return;
|
|
446
374
|
}
|
|
447
375
|
|
|
@@ -449,50 +377,31 @@ export async function downloadFileHandler(req, res) {
|
|
|
449
377
|
const contentType = result.ContentType || 'application/octet-stream';
|
|
450
378
|
const contentLength = result.ContentLength;
|
|
451
379
|
|
|
452
|
-
// Set headers BEFORE piping — this allows the browser to show filename
|
|
453
|
-
// and progress bar even before the first byte arrives
|
|
454
380
|
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
|
455
381
|
res.setHeader('Content-Type', contentType);
|
|
456
382
|
if (contentLength) res.setHeader('Content-Length', contentLength);
|
|
457
383
|
if (result.ETag) res.setHeader('ETag', result.ETag);
|
|
458
384
|
if (result.LastModified) res.setHeader('Last-Modified', result.LastModified.toUTCString());
|
|
459
385
|
|
|
460
|
-
const
|
|
461
|
-
const isSensitiveText = typeLower.startsWith('text/')
|
|
462
|
-
|| typeLower.includes('json')
|
|
463
|
-
|| typeLower.includes('xml')
|
|
464
|
-
|| typeLower.includes('javascript')
|
|
465
|
-
|| typeLower.includes('typescript');
|
|
386
|
+
const isSensitive = isSensitiveTextType(contentType);
|
|
466
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');
|
|
467
389
|
|
|
468
|
-
if (isSensitiveText) {
|
|
469
|
-
res.setHeader('Cache-Control', 'private, no-store');
|
|
470
|
-
} else if (isLargeFile) {
|
|
471
|
-
res.setHeader('Cache-Control', 'private, max-age=600, must-revalidate');
|
|
472
|
-
} else {
|
|
473
|
-
res.setHeader('Cache-Control', 'private, max-age=1800, must-revalidate');
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Use pipeline for proper backpressure + cleanup. On abort, the
|
|
477
|
-
// pipeline will be rejected and we clean up the S3 stream.
|
|
478
390
|
try {
|
|
479
391
|
await pipeline(result.Body, res);
|
|
480
392
|
} catch (pipeErr) {
|
|
481
|
-
// Aborted by client — expected, not an error
|
|
482
393
|
if (aborted) {
|
|
483
|
-
|
|
394
|
+
destroyStream(result.Body);
|
|
484
395
|
return;
|
|
485
396
|
}
|
|
486
397
|
throw pipeErr;
|
|
487
398
|
}
|
|
488
399
|
} catch (error) {
|
|
489
|
-
// Don't try to send error response if client already disconnected
|
|
490
400
|
if (aborted) return;
|
|
491
|
-
|
|
492
401
|
console.error("Error downloading file:", error);
|
|
493
|
-
if (error.message
|
|
402
|
+
if (error.message?.includes('File not found')) {
|
|
494
403
|
res.status(404).json({ message: "File not found", key: req.params.key });
|
|
495
|
-
} else if (error.message
|
|
404
|
+
} else if (error.message?.includes('Access denied')) {
|
|
496
405
|
res.status(403).json({ message: "Access denied" });
|
|
497
406
|
} else if (!res.headersSent) {
|
|
498
407
|
res.status(500).json({ message: "Download failed", error: error.message });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "fs";
|
|
1
2
|
import path from "path";
|
|
2
3
|
import { resolveBucketName, getAvailableBucketNames } from "../services/s3.service.js";
|
|
3
4
|
import { renderPage, mbkautheVar } from "mbkauthe";
|
|
@@ -11,49 +12,37 @@ export function renderBucketDashboard(req, res) {
|
|
|
11
12
|
const configuredDefaultBucket = mbkautheVar?.bucket;
|
|
12
13
|
const isPortal = mbkautheVar?.APP_NAME === "portal";
|
|
13
14
|
const bucketfilePath = isPortal ? "bucketportal.handlebars" : "bucket.handlebars";
|
|
14
|
-
|
|
15
|
+
|
|
16
|
+
const mainLayoutPath = path.resolve(process.cwd(), "views", "layouts", "main");
|
|
17
|
+
const hasMainLayout = fs.existsSync(`${mainLayoutPath}.handlebars`) || fs.existsSync(mainLayoutPath);
|
|
18
|
+
const portalLayout = (isPortal && hasMainLayout) ? mainLayoutPath : false;
|
|
15
19
|
|
|
16
20
|
let selectedBucket;
|
|
21
|
+
let error = req.query.error;
|
|
17
22
|
|
|
18
23
|
try {
|
|
19
24
|
selectedBucket = resolveBucketName(req.query.bucket);
|
|
20
25
|
} catch (err) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
name,
|
|
24
|
-
isSelected: name === fallbackSelected,
|
|
25
|
-
isDefault: name === configuredDefaultBucket
|
|
26
|
-
}));
|
|
27
|
-
return renderPage(req, res, bucketfilePath, isPortal, {
|
|
28
|
-
page: "Admin Bucket",
|
|
29
|
-
helpers: commonHandlebarsHelpers,
|
|
30
|
-
...(isPortal ? { layout: portalLayout } : {}),
|
|
31
|
-
bucketvar: mbkautheVar?.bucket,
|
|
32
|
-
selectedBucket: fallbackSelected,
|
|
33
|
-
bucketOptions,
|
|
34
|
-
APP_NAME: mbkautheVar?.APP_NAME,
|
|
35
|
-
message: req.query.message,
|
|
36
|
-
error: req.query.error || err.message,
|
|
37
|
-
availableBuckets: allBuckets
|
|
38
|
-
});
|
|
26
|
+
selectedBucket = mbkautheVar?.bucket;
|
|
27
|
+
error ||= err.message;
|
|
39
28
|
}
|
|
40
29
|
|
|
41
|
-
const bucketOptions = allBuckets.map(
|
|
30
|
+
const bucketOptions = allBuckets.map(name => ({
|
|
42
31
|
name,
|
|
43
32
|
isSelected: name === selectedBucket,
|
|
44
33
|
isDefault: name === configuredDefaultBucket
|
|
45
34
|
}));
|
|
46
35
|
|
|
47
|
-
renderPage(req, res, bucketfilePath,
|
|
36
|
+
renderPage(req, res, bucketfilePath, portalLayout ? true : false, {
|
|
48
37
|
page: "Admin Bucket",
|
|
49
38
|
helpers: commonHandlebarsHelpers,
|
|
50
|
-
...(
|
|
39
|
+
...(portalLayout ? { layout: portalLayout } : { layout: false }),
|
|
51
40
|
bucketvar: selectedBucket,
|
|
52
41
|
selectedBucket,
|
|
53
42
|
bucketOptions,
|
|
54
43
|
APP_NAME: mbkautheVar?.APP_NAME,
|
|
55
44
|
message: req.query.message,
|
|
56
|
-
error
|
|
45
|
+
error,
|
|
57
46
|
availableBuckets: allBuckets
|
|
58
47
|
});
|
|
59
48
|
}
|
|
@@ -1,37 +1,34 @@
|
|
|
1
1
|
import { renderPage, mbkautheVar } from "mbkauthe";
|
|
2
2
|
import { packageJson, getLatestVersion } from "../config/index.js";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
* Render the mbkbucket info page (Handlebars).
|
|
6
|
-
*/
|
|
7
|
-
export async function infoPage(req, res) {
|
|
4
|
+
async function getInfoData() {
|
|
8
5
|
let latestVersion = 'unknown';
|
|
9
6
|
try {
|
|
10
|
-
latestVersion = await getLatestVersion();
|
|
7
|
+
latestVersion = (await getLatestVersion()) || 'unknown';
|
|
11
8
|
} catch (err) {
|
|
12
|
-
console.error("[
|
|
9
|
+
console.error("[mbkbucket] Error fetching latest version:", err);
|
|
13
10
|
}
|
|
14
|
-
|
|
15
|
-
page: "MBK Bucket Info",
|
|
11
|
+
return {
|
|
16
12
|
CurrentVersion: packageJson.version,
|
|
17
13
|
latestVersion,
|
|
18
14
|
APP_NAME: mbkautheVar?.APP_NAME
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Render the mbkbucket info page (Handlebars).
|
|
20
|
+
*/
|
|
21
|
+
export async function infoPage(req, res) {
|
|
22
|
+
const data = await getInfoData();
|
|
23
|
+
renderPage(req, res, "mbkbucket_info.handlebars", false, {
|
|
24
|
+
page: "MBK Bucket Info",
|
|
25
|
+
...data
|
|
19
26
|
});
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
/**
|
|
23
30
|
* Return bucket info as JSON.
|
|
24
31
|
*/
|
|
25
|
-
export async function infoJson(
|
|
26
|
-
|
|
27
|
-
try {
|
|
28
|
-
latestVersion = await getLatestVersion();
|
|
29
|
-
} catch (err) {
|
|
30
|
-
console.error("[mbkauthe] Error fetching package-lock.json:", err);
|
|
31
|
-
}
|
|
32
|
-
res.json({
|
|
33
|
-
CurrentVersion: packageJson.version,
|
|
34
|
-
latestVersion,
|
|
35
|
-
APP_NAME: mbkautheVar?.APP_NAME
|
|
36
|
-
});
|
|
32
|
+
export async function infoJson(_req, res) {
|
|
33
|
+
res.json(await getInfoData());
|
|
37
34
|
}
|