mbkbucket 2.1.1 → 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.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Centralized error classification for API routes.
3
+ * Maps error messages to HTTP status codes and machine-readable error codes.
4
+ */
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') {
13
+ const message = String(error?.message || fallbackMessage);
14
+ const lower = message.toLowerCase();
15
+
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 };
35
+ }
36
+
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
+ 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 };
60
+ }
61
+
62
+ /**
63
+ * Send a classified API error as JSON.
64
+ */
65
+ 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 });
68
+ }
69
+
70
+ /**
71
+ * Send a classified view error as JSON.
72
+ */
73
+ 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 });
76
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Shared helper utilities used across controllers and routes.
3
+ */
4
+
5
+ /**
6
+ * HTML-escape a string value to prevent XSS.
7
+ */
8
+ export function escapeHtml(value = '') {
9
+ return String(value)
10
+ .replace(/&/g, '&')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;')
14
+ .replace(/'/g, '&#39;');
15
+ }
16
+
17
+ /**
18
+ * Extract the base filename from a path (everything after the last '/').
19
+ */
20
+ export function getBaseName(path = '') {
21
+ const value = String(path || '');
22
+ const lastSlashIndex = value.lastIndexOf('/');
23
+ return lastSlashIndex === -1 ? value : value.substring(lastSlashIndex + 1);
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // MIME type & file extension constants (used by view controller)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export const VIEWABLE_TYPES = new Set([
31
+ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico',
32
+ 'mp4', 'webm', 'ogg', 'avi', 'mov',
33
+ 'mp3', 'wav', 'flac', 'aac', 'm4a',
34
+ 'txt', 'md', 'json', 'xml', 'csv', 'log',
35
+ 'js', 'ts', 'html', 'htm', 'css', 'php', 'py', 'java', 'cpp', 'c', 'h',
36
+ 'cs', 'rb', 'go', 'rs', 'sql', 'sh', 'bat', 'ps1', 'yaml', 'yml',
37
+ 'toml', 'ini', 'conf', 'pdf'
38
+ ]);
39
+
40
+ export const VIEWABLE_TYPES_SORTED = Array.from(VIEWABLE_TYPES).sort();
41
+
42
+ export const STATIC_ASSET_TYPES = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'css', 'js']);
43
+ export const VIDEO_TYPES = new Set(['mp4', 'webm', 'ogg', 'avi', 'mov']);
44
+ export const AUDIO_TYPES = new Set(['mp3', 'wav', 'flac', 'aac', 'm4a']);
45
+
46
+ export const MIME_TYPES = {
47
+ // 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',
51
+ // Videos
52
+ 'mp4': 'video/mp4', 'webm': 'video/webm', 'ogg': 'video/ogg',
53
+ 'avi': 'video/x-msvideo', 'mov': 'video/quicktime',
54
+ // Audio
55
+ 'mp3': 'audio/mpeg', 'wav': 'audio/wav', 'flac': 'audio/flac',
56
+ 'aac': 'audio/aac', 'm4a': 'audio/mp4',
57
+ // 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'
75
+ };
76
+
77
+ /**
78
+ * Check if a content type is sensitive (text-based) and should not be cached.
79
+ */
80
+ 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');
87
+ }
88
+
89
+ /**
90
+ * Build a Cache-Control header value based on content type and flags.
91
+ */
92
+ export function buildCacheControl(contentType, { isStaticAsset = false, supportsRanges = false, publicCache = false } = {}) {
93
+ if (publicCache) {
94
+ if (isStaticAsset) return 'public, max-age=3600, immutable';
95
+ if (supportsRanges) return 'public, max-age=86400, must-revalidate';
96
+ return 'public, max-age=300, must-revalidate';
97
+ }
98
+
99
+ 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';
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Range header parsing (used by view controller for media streaming)
106
+ // ---------------------------------------------------------------------------
107
+
108
+ const DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB
109
+
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
+ export function parseRangeHeader(rangeHeader, totalSize) {
115
+ if (!totalSize || totalSize <= 0) return null;
116
+
117
+ const match = /^bytes=(\d*)-(\d*)$/.exec(String(rangeHeader || '').trim());
118
+ if (!match) return null;
119
+
120
+ const [, startStr, endStr] = match;
121
+ let start, end;
122
+
123
+ if (startStr === '') {
124
+ // suffix range: bytes=-N (last N bytes)
125
+ const suffix = parseInt(endStr, 10);
126
+ if (isNaN(suffix) || suffix <= 0) return null;
127
+ start = Math.max(0, totalSize - suffix);
128
+ end = totalSize - 1;
129
+ } else {
130
+ start = parseInt(startStr, 10);
131
+ if (isNaN(start) || start < 0) return null;
132
+ if (endStr === '') {
133
+ end = Math.min(start + DEFAULT_CHUNK_SIZE - 1, totalSize - 1);
134
+ } else {
135
+ end = parseInt(endStr, 10);
136
+ if (isNaN(end) || end < start) return null;
137
+ }
138
+ }
139
+
140
+ if (start >= totalSize || start > end || end >= totalSize) return null;
141
+
142
+ return { start, end, total: totalSize, size: end - start + 1 };
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Player page HTML template (used by view controller)
147
+ // ---------------------------------------------------------------------------
148
+
149
+ export function renderPlayerPage(safeFileName, encodedKey, bucketQuery) {
150
+ return `<!doctype html>
151
+ <html lang="en">
152
+ <head>
153
+ <meta charset="utf-8">
154
+ <meta name="viewport" content="width=device-width,initial-scale=1">
155
+ <title>Preview: ${safeFileName}</title>
156
+ <style>
157
+ * { margin: 0; padding: 0; box-sizing: border-box; }
158
+ body { margin: 0; display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #000; color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
159
+ .player-container { width: 100%; max-width: 100vw; max-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
160
+ video { width: 100%; height: auto; max-width: 100%; max-height: calc(100vh - 40px); object-fit: contain; background: #000; }
161
+ .file-name { position: fixed; top: 10px; left: 10px; background: rgba(0, 0, 0, 0.8); padding: 8px 16px; border-radius: 4px; font-size: 14px; z-index: 10; }
162
+ </style>
163
+ </head>
164
+ <body>
165
+ <div class="file-name">${safeFileName}</div>
166
+ <div class="player-container">
167
+ <video controls preload="metadata" src="/mbkbucket/view/${encodedKey}${bucketQuery}">
168
+ Your browser does not support the video tag.
169
+ </video>
170
+ </div>
171
+ </body>
172
+ </html>`;
173
+ }
@@ -7,6 +7,11 @@ if (process.env.DEBUG) {
7
7
  createDebug.enable(process.env.DEBUG);
8
8
  }
9
9
 
10
+ /**
11
+ * Creates a namespaced debug logger (e.g., `mbkbucket:s3`, `mbkbucket:api`).
12
+ * @param {string} namespace
13
+ * @returns {import("debug").Debugger}
14
+ */
10
15
  export function createLogger(namespace) {
11
16
  return createDebug(`mbkbucket:${namespace}`);
12
17
  }
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { parseAndValidateMbkbucketVar, parseAndValidateBucketConnection, normalizeBooleanLike } from '../lib/config/validation.js';
3
+ import { parseAndValidateMbkbucketVar, parseAndValidateBucketConnection, normalizeBooleanLike } from '../src/config/validation.js';
4
4
 
5
5
  test('parseAndValidateMbkbucketVar applies defaults when empty', () => {
6
6
  const cfg = parseAndValidateMbkbucketVar(undefined);
@@ -1,28 +0,0 @@
1
- import express from "express";
2
- import { renderPage, mbkautheVar } from "mbkauthe";
3
- import { packageJson, getLatestVersion } from "../config/index.js";
4
-
5
- const router = express.Router();
6
-
7
- router.get(['/info', "/i"], async (req, res) => {
8
- let latestVersion = 'unknown';
9
- try {
10
- latestVersion = await getLatestVersion();
11
- } catch (err) {
12
- console.error("[mbkauthe] Error fetching package-lock.json:", err);
13
- }
14
- renderPage(req, res, "mbkbucket_info.handlebars", false, { page: "MBK Bucket Info", CurrentVersion: packageJson.version, latestVersion, APP_NAME: mbkautheVar?.APP_NAME });
15
- });
16
-
17
-
18
- router.get(['/info.json', "/i.json"], async (req, res) => {
19
- let latestVersion = 'unknown';
20
- try {
21
- latestVersion = await getLatestVersion();
22
- } catch (err) {
23
- console.error("[mbkauthe] Error fetching package-lock.json:", err);
24
- }
25
- res.json({CurrentVersion:packageJson.version, latestVersion, APP_NAME: mbkautheVar?.APP_NAME });
26
- });
27
-
28
- export default router;