mbkbucket 3.3.1 → 3.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/index.js +2 -12
  2. package/lib/cli.js +178 -512
  3. package/lib/public/bucketadmin.js +1 -1
  4. package/lib/src/app.js +37 -50
  5. package/lib/src/cli/auth-helper.js +24 -103
  6. package/lib/src/config/index.js +69 -94
  7. package/lib/src/config/validation.js +14 -30
  8. package/lib/src/controllers/api.controller.js +85 -176
  9. package/lib/src/controllers/dashboard.controller.js +12 -23
  10. package/lib/src/controllers/info.controller.js +17 -20
  11. package/lib/src/controllers/view.controller.js +23 -70
  12. package/lib/src/middleware/async-handler.js +1 -16
  13. package/lib/src/middleware/bucket-resolver.js +7 -13
  14. package/lib/src/middleware/error-handler.js +6 -14
  15. package/lib/src/middleware/rate-limiter.js +2 -10
  16. package/lib/src/middleware/security.js +1 -17
  17. package/lib/src/routes/api.routes.js +21 -47
  18. package/lib/src/routes/index.js +1 -9
  19. package/lib/src/routes/info.routes.js +2 -3
  20. package/lib/src/routes/view.routes.js +3 -8
  21. package/lib/src/services/s3.service.js +157 -349
  22. package/lib/src/utils/errors.js +28 -59
  23. package/lib/src/utils/helpers.js +124 -70
  24. package/package.json +6 -1
  25. /package/{lib/views → views}/bucket.handlebars +0 -0
  26. /package/{lib/views → views}/bucketadmin_alerts.handlebars +0 -0
  27. /package/{lib/views → views}/bucketadmin_delete_modal.handlebars +0 -0
  28. /package/{lib/views → views}/bucketadmin_filelist_skeleton.handlebars +0 -0
  29. /package/{lib/views → views}/bucketadmin_newfolder_modal.handlebars +0 -0
  30. /package/{lib/views → views}/bucketadmin_preview_modal.handlebars +0 -0
  31. /package/{lib/views → views}/bucketadmin_upload_modal.handlebars +0 -0
  32. /package/{lib/views → views}/bucketadmincontent.handlebars +0 -0
  33. /package/{lib/views → views}/bucketportal.handlebars +0 -0
  34. /package/{lib/views → views}/mbkbucket_info.handlebars +0 -0
@@ -1,76 +1,45 @@
1
1
  /**
2
- * Centralized error classification for API routes.
2
+ * Centralized error classification for API and View routes.
3
3
  * Maps error messages to HTTP status codes and machine-readable error codes.
4
4
  */
5
5
 
6
- /**
7
- * Classify an API-level error into { status, code, message }.
8
- * @param {Error} error
9
- * @param {string} fallbackMessage
10
- * @returns {{ status: number, code: string, message: string }}
11
- */
12
- export function classifyApiError(error, fallbackMessage = 'Request failed') {
6
+ const API_ERROR_RULES = [
7
+ { match: 'bucketconnection environment variable is not set', status: 503, code: 'BUCKET_CONFIG_ERROR' },
8
+ { match: ['no bucket selected', 'not found in bucketconnection'], status: 400, code: 'INVALID_BUCKET' },
9
+ { match: 'already exists', status: 409, code: 'CONFLICT' },
10
+ { match: 'not found', status: 404, code: 'NOT_FOUND' },
11
+ { match: 'access denied', status: 403, code: 'ACCESS_DENIED' },
12
+ { match: ['required', 'invalid', 'must be'], status: 400, code: 'VALIDATION_ERROR' },
13
+ ];
14
+
15
+ const VIEW_ERROR_RULES = [
16
+ { match: ['no bucket selected', 'not found in bucketconnection'], status: 400, code: 'INVALID_BUCKET' },
17
+ { match: 'access denied', status: 403, code: 'ACCESS_DENIED' },
18
+ { match: 'not found', status: 404, code: 'NOT_FOUND' },
19
+ { match: ['invalid', 'required'], status: 400, code: 'VALIDATION_ERROR' },
20
+ ];
21
+
22
+ function matchError(rules, error, fallbackMessage) {
13
23
  const message = String(error?.message || fallbackMessage);
14
24
  const lower = message.toLowerCase();
25
+ const found = rules.find(r => Array.isArray(r.match) ? r.match.some(m => lower.includes(m)) : lower.includes(r.match));
26
+ return { status: found?.status ?? 500, code: found?.code ?? 'INTERNAL_ERROR', message };
27
+ }
15
28
 
16
- if (lower.includes('bucketconnection environment variable is not set')) {
17
- return { status: 503, code: 'BUCKET_CONFIG_ERROR', message };
18
- }
19
- if (lower.includes('no bucket selected') || lower.includes('not found in bucketconnection')) {
20
- return { status: 400, code: 'INVALID_BUCKET', message };
21
- }
22
- if (lower.includes('already exists')) {
23
- return { status: 409, code: 'CONFLICT', message };
24
- }
25
- if (lower.includes('not found')) {
26
- return { status: 404, code: 'NOT_FOUND', message };
27
- }
28
- if (lower.includes('access denied')) {
29
- return { status: 403, code: 'ACCESS_DENIED', message };
30
- }
31
- if (lower.includes('required') || lower.includes('invalid') || lower.includes('must be')) {
32
- return { status: 400, code: 'VALIDATION_ERROR', message };
33
- }
34
- return { status: 500, code: 'INTERNAL_ERROR', message };
29
+ export function classifyApiError(error, fallbackMessage = 'Request failed') {
30
+ return matchError(API_ERROR_RULES, error, fallbackMessage);
35
31
  }
36
32
 
37
- /**
38
- * Classify a view-level error into { status, code, message }.
39
- * @param {Error} error
40
- * @param {string} fallbackMessage
41
- * @returns {{ status: number, code: string, message: string }}
42
- */
43
33
  export function classifyViewError(error, fallbackMessage = 'Request failed') {
44
- const message = String(error?.message || fallbackMessage);
45
- const lower = message.toLowerCase();
46
-
47
- if (lower.includes('no bucket selected') || lower.includes('not found in bucketconnection')) {
48
- return { status: 400, code: 'INVALID_BUCKET', message };
49
- }
50
- if (lower.includes('access denied')) {
51
- return { status: 403, code: 'ACCESS_DENIED', message };
52
- }
53
- if (lower.includes('not found')) {
54
- return { status: 404, code: 'NOT_FOUND', message };
55
- }
56
- if (lower.includes('invalid') || lower.includes('required')) {
57
- return { status: 400, code: 'VALIDATION_ERROR', message };
58
- }
59
- return { status: 500, code: 'INTERNAL_ERROR', message };
34
+ return matchError(VIEW_ERROR_RULES, error, fallbackMessage);
60
35
  }
61
36
 
62
- /**
63
- * Send a classified API error as JSON.
64
- */
65
37
  export function sendApiError(res, error, fallbackMessage = 'Request failed') {
66
- const mapped = classifyApiError(error, fallbackMessage);
67
- return res.status(mapped.status).json({ success: false, code: mapped.code, error: mapped.message });
38
+ const { status, code, message } = classifyApiError(error, fallbackMessage);
39
+ return res.status(status).json({ success: false, code, error: message });
68
40
  }
69
41
 
70
- /**
71
- * Send a classified view error as JSON.
72
- */
73
42
  export function sendViewError(res, error, fallbackMessage = 'Request failed') {
74
- const mapped = classifyViewError(error, fallbackMessage);
75
- return res.status(mapped.status).json({ code: mapped.code, message: mapped.message });
43
+ const { status, code, message } = classifyViewError(error, fallbackMessage);
44
+ return res.status(status).json({ code, message });
76
45
  }
@@ -1,30 +1,98 @@
1
1
  /**
2
- * Shared helper utilities used across controllers and routes.
2
+ * Shared helper utilities used across controllers, services, and CLI.
3
3
  */
4
4
 
5
- /**
6
- * HTML-escape a string value to prevent XSS.
7
- */
5
+ const HTML_ESCAPE_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
6
+
8
7
  export function escapeHtml(value = '') {
9
- return String(value)
10
- .replace(/&/g, '&amp;')
11
- .replace(/</g, '&lt;')
12
- .replace(/>/g, '&gt;')
13
- .replace(/"/g, '&quot;')
14
- .replace(/'/g, '&#39;');
8
+ return String(value ?? '').replace(/[&<>"']/g, c => HTML_ESCAPE_MAP[c]);
15
9
  }
16
10
 
17
- /**
18
- * Extract the base filename from a path (everything after the last '/').
19
- */
20
11
  export function getBaseName(path = '') {
21
- const value = String(path || '');
12
+ const value = String(path ?? '');
22
13
  const lastSlashIndex = value.lastIndexOf('/');
23
- return lastSlashIndex === -1 ? value : value.substring(lastSlashIndex + 1);
14
+ return lastSlashIndex === -1 ? value : value.slice(lastSlashIndex + 1);
15
+ }
16
+
17
+ export function getFileExt(fileName = '') {
18
+ const name = getBaseName(fileName);
19
+ const dotIndex = name.lastIndexOf('.');
20
+ return dotIndex === -1 ? '' : name.slice(dotIndex + 1).toLowerCase();
21
+ }
22
+
23
+ export function trimSlashes(str = '') {
24
+ return String(str ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
25
+ }
26
+
27
+ export function trimLeadingSlashes(str = '') {
28
+ return String(str ?? '').replace(/^\/+/, '');
29
+ }
30
+
31
+ export function getFolderPath(key = '') {
32
+ const s = String(key ?? '');
33
+ const lastSlash = s.lastIndexOf('/');
34
+ return lastSlash === -1 ? '' : s.slice(0, lastSlash + 1);
35
+ }
36
+
37
+ export function normalizeKeyParam(rawKey) {
38
+ return Array.isArray(rawKey) ? rawKey.join('/') : String(rawKey || '');
39
+ }
40
+
41
+ export function buildKey(prefix, fileName) {
42
+ const cleanPrefix = trimSlashes(prefix);
43
+ const cleanName = trimLeadingSlashes(fileName);
44
+ return cleanPrefix ? `${cleanPrefix}/${cleanName}` : cleanName;
45
+ }
46
+
47
+ export function nowIso() {
48
+ return new Date().toISOString();
49
+ }
50
+
51
+ export function formatBytes(bytes) {
52
+ if (!bytes || bytes === 0) return '0 B';
53
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
54
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
55
+ return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
56
+ }
57
+
58
+ export function formatDate(iso) {
59
+ try {
60
+ return new Date(iso).toLocaleString();
61
+ } catch {
62
+ return iso || '—';
63
+ }
64
+ }
65
+
66
+ export async function streamToBuffer(stream) {
67
+ if (Buffer.isBuffer(stream)) return stream;
68
+ const chunks = [];
69
+ if (stream?.[Symbol.asyncIterator] || stream?.transformToWeb) {
70
+ for await (const chunk of stream) {
71
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
72
+ }
73
+ return Buffer.concat(chunks);
74
+ }
75
+ if (stream?.getReader) {
76
+ const reader = stream.getReader();
77
+ while (true) {
78
+ const { done, value } = await reader.read();
79
+ if (done) break;
80
+ chunks.push(Buffer.isBuffer(value) ? value : Buffer.from(value));
81
+ }
82
+ return Buffer.concat(chunks);
83
+ }
84
+ return Buffer.concat(chunks);
85
+ }
86
+
87
+ export function destroyStream(stream) {
88
+ if (stream && typeof stream.destroy === 'function') {
89
+ stream.removeAllListeners?.();
90
+ stream.destroy();
91
+ }
24
92
  }
25
93
 
26
94
  // ---------------------------------------------------------------------------
27
- // MIME type & file extension constants (used by view controller)
95
+ // MIME type & file extension constants
28
96
  // ---------------------------------------------------------------------------
29
97
 
30
98
  export const VIEWABLE_TYPES = new Set([
@@ -37,91 +105,79 @@ export const VIEWABLE_TYPES = new Set([
37
105
  'toml', 'ini', 'conf', 'pdf'
38
106
  ]);
39
107
 
40
- export const VIEWABLE_TYPES_SORTED = Array.from(VIEWABLE_TYPES).sort();
41
-
108
+ export const VIEWABLE_TYPES_SORTED = [...VIEWABLE_TYPES].sort();
42
109
  export const STATIC_ASSET_TYPES = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'css', 'js']);
43
110
  export const VIDEO_TYPES = new Set(['mp4', 'webm', 'ogg', 'avi', 'mov']);
44
111
  export const AUDIO_TYPES = new Set(['mp3', 'wav', 'flac', 'aac', 'm4a']);
45
112
 
46
113
  export const MIME_TYPES = {
47
114
  // Images
48
- 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
49
- 'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
50
- 'bmp': 'image/bmp', 'ico': 'image/x-icon',
115
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
116
+ gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
117
+ bmp: 'image/bmp', ico: 'image/x-icon',
51
118
  // Videos
52
- 'mp4': 'video/mp4', 'webm': 'video/webm', 'ogg': 'video/ogg',
53
- 'avi': 'video/x-msvideo', 'mov': 'video/quicktime',
119
+ mp4: 'video/mp4', webm: 'video/webm', ogg: 'video/ogg',
120
+ avi: 'video/x-msvideo', mov: 'video/quicktime',
54
121
  // Audio
55
- 'mp3': 'audio/mpeg', 'wav': 'audio/wav', 'flac': 'audio/flac',
56
- 'aac': 'audio/aac', 'm4a': 'audio/mp4',
122
+ mp3: 'audio/mpeg', wav: 'audio/wav', flac: 'audio/flac',
123
+ aac: 'audio/aac', m4a: 'audio/mp4',
57
124
  // Text / Code
58
- 'txt': 'text/plain; charset=utf-8', 'md': 'text/markdown; charset=utf-8',
59
- 'json': 'application/json; charset=utf-8', 'xml': 'application/xml; charset=utf-8',
60
- 'csv': 'text/csv; charset=utf-8', 'log': 'text/plain; charset=utf-8',
61
- 'js': 'text/javascript; charset=utf-8', 'ts': 'text/typescript; charset=utf-8',
62
- 'html': 'text/html; charset=utf-8', 'htm': 'text/html; charset=utf-8',
63
- 'css': 'text/css; charset=utf-8', 'php': 'text/x-php; charset=utf-8',
64
- 'py': 'text/x-python; charset=utf-8', 'java': 'text/x-java-source; charset=utf-8',
65
- 'cpp': 'text/x-c++src; charset=utf-8', 'c': 'text/x-csrc; charset=utf-8',
66
- 'h': 'text/x-chdr; charset=utf-8', 'cs': 'text/x-csharp; charset=utf-8',
67
- 'rb': 'text/x-ruby; charset=utf-8', 'go': 'text/x-go; charset=utf-8',
68
- 'rs': 'text/x-rust; charset=utf-8', 'sql': 'text/x-sql; charset=utf-8',
69
- 'sh': 'text/x-shellscript; charset=utf-8', 'bat': 'text/x-msdos-batch; charset=utf-8',
70
- 'ps1': 'text/x-powershell; charset=utf-8', 'yaml': 'text/x-yaml; charset=utf-8',
71
- 'yml': 'text/x-yaml; charset=utf-8', 'toml': 'text/x-toml; charset=utf-8',
72
- 'ini': 'text/x-ini; charset=utf-8', 'conf': 'text/x-conf; charset=utf-8',
73
- // Documents
74
- 'pdf': 'application/pdf'
125
+ txt: 'text/plain; charset=utf-8', md: 'text/markdown; charset=utf-8',
126
+ json: 'application/json; charset=utf-8', xml: 'application/xml; charset=utf-8',
127
+ csv: 'text/csv; charset=utf-8', log: 'text/plain; charset=utf-8',
128
+ js: 'text/javascript; charset=utf-8', ts: 'text/typescript; charset=utf-8',
129
+ html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
130
+ css: 'text/css; charset=utf-8', php: 'text/x-php; charset=utf-8',
131
+ py: 'text/x-python; charset=utf-8', java: 'text/x-java-source; charset=utf-8',
132
+ cpp: 'text/x-c++src; charset=utf-8', c: 'text/x-csrc; charset=utf-8',
133
+ h: 'text/x-chdr; charset=utf-8', cs: 'text/x-csharp; charset=utf-8',
134
+ rb: 'text/x-ruby; charset=utf-8', go: 'text/x-go; charset=utf-8',
135
+ rs: 'text/x-rust; charset=utf-8', sql: 'text/x-sql; charset=utf-8',
136
+ sh: 'text/x-shellscript; charset=utf-8', bat: 'text/x-msdos-batch; charset=utf-8',
137
+ ps1: 'text/x-powershell; charset=utf-8', yaml: 'text/x-yaml; charset=utf-8',
138
+ yml: 'text/x-yaml; charset=utf-8', toml: 'text/x-toml; charset=utf-8',
139
+ ini: 'text/x-ini; charset=utf-8', conf: 'text/x-conf; charset=utf-8',
140
+ // Archives & Documents
141
+ zip: 'application/zip', gz: 'application/gzip', tar: 'application/x-tar',
142
+ pdf: 'application/pdf'
75
143
  };
76
144
 
77
- /**
78
- * Check if a content type is sensitive (text-based) and should not be cached.
79
- */
145
+ export function getMimeType(fileName, fallback = 'application/octet-stream') {
146
+ return MIME_TYPES[getFileExt(fileName)] || fallback;
147
+ }
148
+
80
149
  export function isSensitiveTextType(contentType = '') {
81
- const type = String(contentType || '').toLowerCase();
82
- return type.startsWith('text/')
83
- || type.includes('json')
84
- || type.includes('xml')
85
- || type.includes('javascript')
86
- || type.includes('typescript');
150
+ const type = String(contentType ?? '').toLowerCase();
151
+ return type.startsWith('text/') || /json|xml|javascript|typescript/.test(type);
87
152
  }
88
153
 
89
- /**
90
- * Build a Cache-Control header value based on content type and flags.
91
- */
92
154
  export function buildCacheControl(contentType, { isStaticAsset = false, supportsRanges = false, publicCache = false } = {}) {
93
155
  if (publicCache) {
94
156
  if (isStaticAsset) return 'public, max-age=3600, immutable';
95
157
  if (supportsRanges) return 'public, max-age=86400, must-revalidate';
96
158
  return 'public, max-age=300, must-revalidate';
97
159
  }
98
-
99
160
  if (isSensitiveTextType(contentType)) return 'private, no-store';
100
- if (supportsRanges) return 'private, max-age=1800, must-revalidate';
101
- return 'private, max-age=300, must-revalidate';
161
+ return supportsRanges ? 'private, max-age=1800, must-revalidate' : 'private, max-age=300, must-revalidate';
102
162
  }
103
163
 
104
164
  // ---------------------------------------------------------------------------
105
- // Range header parsing (used by view controller for media streaming)
165
+ // Range header parsing
106
166
  // ---------------------------------------------------------------------------
107
167
 
108
168
  const DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB
109
169
 
110
- /**
111
- * Parse an HTTP Range header (`bytes=start-end`) and compute validated byte offsets.
112
- * Returns `null` if the range is invalid or unsatisfiable (caller should send 416).
113
- */
114
170
  export function parseRangeHeader(rangeHeader, totalSize) {
115
171
  if (!totalSize || totalSize <= 0) return null;
116
172
 
117
- const match = /^bytes=(\d*)-(\d*)$/.exec(String(rangeHeader || '').trim());
173
+ const match = /^bytes=(\d*)-(\d*)$/.exec(String(rangeHeader ?? '').trim());
118
174
  if (!match) return null;
119
175
 
120
176
  const [, startStr, endStr] = match;
121
- let start, end;
177
+ let start;
178
+ let end;
122
179
 
123
180
  if (startStr === '') {
124
- // suffix range: bytes=-N (last N bytes)
125
181
  const suffix = parseInt(endStr, 10);
126
182
  if (isNaN(suffix) || suffix <= 0) return null;
127
183
  start = Math.max(0, totalSize - suffix);
@@ -138,12 +194,11 @@ export function parseRangeHeader(rangeHeader, totalSize) {
138
194
  }
139
195
 
140
196
  if (start >= totalSize || start > end || end >= totalSize) return null;
141
-
142
197
  return { start, end, total: totalSize, size: end - start + 1 };
143
198
  }
144
199
 
145
200
  // ---------------------------------------------------------------------------
146
- // Player page HTML template (used by view controller)
201
+ // Player page HTML template
147
202
  // ---------------------------------------------------------------------------
148
203
 
149
204
  export function renderPlayerPage(safeFileName, encodedKey, bucketQuery) {
@@ -186,12 +241,11 @@ export const commonHandlebarsHelpers = {
186
241
  gte: (a, b) => a >= b,
187
242
  lt: (a, b) => a < b,
188
243
  lte: (a, b) => a <= b,
189
- includes: (arr, val) => (Array.isArray(arr) ? arr.includes(val) : false),
244
+ includes: (arr, val) => Array.isArray(arr) && arr.includes(val),
190
245
  json: (obj) => JSON.stringify(obj, null, 2),
191
246
  jsonStringify: (obj) => JSON.stringify(obj),
192
247
  getInitials: (username) => {
193
248
  if (!username) return '?';
194
- const parts = username.split(/[._]/);
195
- return parts.map(p => p.charAt(0)).join('').toUpperCase().substring(0, 2);
249
+ return username.split(/[._]/).map(p => p.charAt(0)).join('').toUpperCase().slice(0, 2);
196
250
  },
197
251
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mbkbucket",
3
- "version": "3.3.1",
3
+ "version": "3.3.2",
4
4
  "description": "Flexible S3/R2 bucket management — library, Express router, and CLI for mbktech.org applications. Provides upload, download, listing, multipart utilities, signed URLs, and a command-line interface with built-in configuration validation and health checks",
5
5
  "author": "Muhammad Bin Khalid",
6
6
  "homepage": "https://github.com/MIbnEKhalid/mbkbucket#readme",
@@ -15,6 +15,10 @@
15
15
  "dev": "cross-env NODE_ENV=dev nodemon index.js",
16
16
  "test": "node --test"
17
17
  },
18
+ "imports": {
19
+ "#helpers": "./lib/src/utils/helpers.js",
20
+ "#logger": "./lib/src/utils/logger.js"
21
+ },
18
22
  "dependencies": {
19
23
  "@aws-sdk/client-s3": "^3.968.0",
20
24
  "@aws-sdk/s3-request-presigner": "^3.1000.0",
@@ -46,6 +50,7 @@
46
50
  },
47
51
  "files": [
48
52
  "lib",
53
+ "views",
49
54
  ".env.example",
50
55
  ".mbkbucket.config.example.json",
51
56
  "cli.js",
File without changes
File without changes
File without changes