mbkbucket 1.0.2 → 1.1.1

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/lib/s3.js ADDED
@@ -0,0 +1,504 @@
1
+ import { S3Client, ListObjectsV2Command, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
2
+ import dotenv from "dotenv";
3
+ import { mbkautheVar } from "mbkauthe";
4
+
5
+ dotenv.config();
6
+
7
+ function parseJsonEnv(envVar, fallback = {}) {
8
+ if (!envVar) return fallback;
9
+ try {
10
+ return JSON.parse(envVar);
11
+ } catch (e) {
12
+ // Log a concise message including the error and a short excerpt of the env value when available
13
+ console.error(`❌ Error parsing JSON for envVar: ${envVar ? String(envVar).slice(0, 200) : 'undefined'} - ${e.message}`);
14
+ return fallback;
15
+ }
16
+ }
17
+
18
+ const r2BucketConfig = parseJsonEnv(process.env.R2_Bucket, {});
19
+ const e2BucketConfig = parseJsonEnv(process.env.E2_Bucket, {});
20
+ const e2_1BucketConfig = parseJsonEnv(process.env.E2_1_Bucket, {});
21
+
22
+ // Function to get bucket config by name or from single configured env var key via mbkautheVar.bucket
23
+ export function getBucketConfig(bucketName = 'r2') {
24
+ // If a single configured bucket is set (the value is the ENV VAR name that contains the JSON config)
25
+ const configuredEnvKey = (mbkautheVar && mbkautheVar.bucket);
26
+ if (configuredEnvKey) {
27
+ const envVal = process.env[configuredEnvKey];
28
+ if (!envVal) throw new Error(`Configured bucket env var '${configuredEnvKey}' is not set`);
29
+ const parsed = parseJsonEnv(envVal, null);
30
+ if (!parsed || typeof parsed !== 'object') {
31
+ throw new Error(`Configured bucket env var '${configuredEnvKey}' contains invalid JSON`);
32
+ }
33
+ return parsed;
34
+ }
35
+
36
+ if (bucketName === 'e2') return e2BucketConfig;
37
+ if (bucketName === 'e2_1') return e2_1BucketConfig;
38
+ return r2BucketConfig;
39
+ }
40
+
41
+ // Default client uses getBucketClient() which respects the configured bucket
42
+ export const bucketClient = getBucketClient();
43
+
44
+ // Helper: get configured APP_NAME; prefer mbkautheVar, then env var
45
+ export function getAppName() {
46
+ const app = (mbkautheVar && mbkautheVar.APP_NAME);
47
+ return (typeof app === 'string' && app.trim()) ? app.trim() : '';
48
+ }
49
+
50
+ export function ensureKeyHasAppPrefix(key = '') {
51
+ const app = getAppName();
52
+ if (!app) throw new Error('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
53
+ if (!key) throw new Error('Key is required');
54
+ let cleaned = String(key).replace(/^\/+/, '');
55
+ // Collapse repeated app prefixes (app/app/... -> app/...)
56
+ const appPrefixRegex = new RegExp(`^(?:${app}\/)+`);
57
+ if (!cleaned.startsWith(`${app}/`)) {
58
+ cleaned = `${app}/${cleaned}`;
59
+ } else {
60
+ cleaned = cleaned.replace(appPrefixRegex, `${app}/`);
61
+ }
62
+ return cleaned;
63
+ }
64
+
65
+
66
+ export function ensurePrefix(prefix = '') {
67
+ const app = getAppName();
68
+ if (!app) throw new Error('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
69
+ let p = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/g, '');
70
+ if (!p) return `${app}`;
71
+ // If prefix already equals app or already starts with app/, normalize and return single app prefix
72
+ if (p === app) return app;
73
+ if (p.startsWith(`${app}/`)) {
74
+ // collapse repeated app/ sequences
75
+ const appPrefixRegex = new RegExp(`^(?:${app}\/)+`);
76
+ return p.replace(appPrefixRegex, `${app}/`);
77
+ }
78
+ return `${app}/${p}`;
79
+ }
80
+
81
+ // Function to get S3 client for specific bucket
82
+ export function getBucketClient(bucketName = 'r2') {
83
+ const config = getBucketConfig(bucketName);
84
+
85
+ return new S3Client({
86
+ region: config.region || 'auto',
87
+ endpoint: config.ENDPOINT,
88
+ credentials: {
89
+ accessKeyId: config.ACCESS_KEY_ID,
90
+ secretAccessKey: config.SECRET_ACCESS_KEY,
91
+ },
92
+ maxAttempts: 3,
93
+ retryMode: 'adaptive',
94
+ requestTimeout: 120000,
95
+ requestHandler: {
96
+ connectionTimeout: 5000,
97
+ socketTimeout: 120000,
98
+ maxSockets: 50,
99
+ keepAlive: true,
100
+ keepAliveMsecs: 1000,
101
+ },
102
+ useAccelerateEndpoint: false,
103
+ forcePathStyle: true,
104
+ });
105
+ }
106
+
107
+ // Health check for R2 connection (moved up for startup test)
108
+ async function checkR2Health() {
109
+ try {
110
+ const startTime = Date.now();
111
+
112
+ const config = getBucketConfig();
113
+ const bucketName = config.BUCKET_NAME;
114
+
115
+ // Try to list objects with minimal result
116
+ const command = new ListObjectsV2Command({
117
+ Bucket: bucketName,
118
+ MaxKeys: 1,
119
+ });
120
+
121
+ const client = getBucketClient();
122
+ const result = await client.send(command);
123
+ const responseTime = Date.now() - startTime;
124
+
125
+ return {
126
+ status: 'healthy',
127
+ responseTime,
128
+ bucket: bucketName,
129
+ region: config.region || 'auto',
130
+ checkedAt: new Date().toISOString()
131
+ };
132
+ } catch (error) {
133
+ return {
134
+ status: 'unhealthy',
135
+ error: error.message,
136
+ bucket: (function(){try{return getBucketConfig().BUCKET_NAME}catch(e){return 'unknown'}})(),
137
+ checkedAt: new Date().toISOString()
138
+ };
139
+ }
140
+ }
141
+
142
+ // Quick diagnostics at startup
143
+ (async () => {
144
+ try {
145
+ console.log('[mbkbucket] mbkautheVar snapshot:', { APP_NAME: mbkautheVar?.APP_NAME, bucket: mbkautheVar?.bucket });
146
+ const health = await checkR2Health();
147
+ if (health.status === 'healthy') {
148
+ console.log(`[mbkbucket] Connected to bucket: ${health.bucket} (${health.responseTime}ms)`);
149
+ } else {
150
+ console.error(`[mbkbucket] R2 connection failed:`, health.error);
151
+ }
152
+ } catch (err) {
153
+ console.error("[mbkbucket] R2 connection test error:", err.message);
154
+ }
155
+ })();
156
+
157
+ // Upload file with enhanced features
158
+ export async function uploadFile(key, fileBuffer, contentType, options = {}) {
159
+ try {
160
+ // Validate inputs
161
+ if (!key || !fileBuffer) {
162
+ throw new Error('Key and file buffer are required');
163
+ }
164
+
165
+ // Enforce app prefix (never allow root keys)
166
+ const keyToUpload = ensureKeyHasAppPrefix(key);
167
+
168
+ const {
169
+ metadata = {},
170
+ cacheControl = 'public, max-age=31536000', // 1 year default
171
+ storageClass = 'STANDARD',
172
+ serverSideEncryption = 'AES256',
173
+ bucketName = 'r2'
174
+ } = options;
175
+
176
+ const client = getBucketClient(bucketName);
177
+ const config = getBucketConfig(bucketName);
178
+ const bucket = config.BUCKET_NAME;
179
+
180
+ console.log(`[mbkbucket] Uploading file to bucket=${bucket} key=${keyToUpload}`);
181
+
182
+ // Add default metadata (R2 supports metadata)
183
+ const defaultMetadata = {
184
+ 'uploaded-at': new Date().toISOString(),
185
+ 'file-size': fileBuffer.length.toString(),
186
+ 'upload-source': 'web-portal',
187
+ ...metadata
188
+ };
189
+
190
+ const command = new PutObjectCommand({
191
+ Bucket: bucket,
192
+ Key: keyToUpload,
193
+ Body: fileBuffer,
194
+ ContentType: contentType,
195
+ CacheControl: cacheControl,
196
+ Metadata: defaultMetadata,
197
+ ServerSideEncryption: serverSideEncryption,
198
+ StorageClass: storageClass,
199
+ // Note: R2 doesn't support object tagging, so we remove the Tagging parameter
200
+ });
201
+
202
+ const result = await client.send(command);
203
+
204
+ return {
205
+ ...result,
206
+ fileSize: fileBuffer.length,
207
+ key: keyToUpload,
208
+ contentType,
209
+ uploadedAt: new Date().toISOString()
210
+ };
211
+ } catch (error) {
212
+ console.error(`Upload failed for key ${key}:`, error);
213
+ throw new Error(`Upload failed: ${error.message}`);
214
+ }
215
+ }
216
+
217
+ // Download file with enhanced features
218
+ export async function downloadFile(key, options = {}) {
219
+ try {
220
+ if (!key) {
221
+ throw new Error('Key is required');
222
+ }
223
+
224
+ // Ensure key is app-prefixed
225
+ const keyToDownload = ensureKeyHasAppPrefix(key);
226
+
227
+ const {
228
+ range = null,
229
+ ifNoneMatch = null,
230
+ ifModifiedSince = null,
231
+ responseCacheControl = null,
232
+ responseContentType = null,
233
+ bucketName = 'r2'
234
+ } = options;
235
+
236
+ const client = getBucketClient(bucketName);
237
+ const config = getBucketConfig(bucketName);
238
+ const bucket = config.BUCKET_NAME;
239
+
240
+ // Log range requests for debugging
241
+ if (range) {
242
+ console.log(`[mbkbucket] Range request for ${keyToDownload}: ${range}`);
243
+ }
244
+
245
+ const command = new GetObjectCommand({
246
+ Bucket: bucket,
247
+ Key: keyToDownload,
248
+ ...(range && { Range: range }),
249
+ ...(ifNoneMatch && { IfNoneMatch: ifNoneMatch }),
250
+ ...(ifModifiedSince && { IfModifiedSince: ifModifiedSince }),
251
+ ...(responseCacheControl && { ResponseCacheControl: responseCacheControl }),
252
+ ...(responseContentType && { ResponseContentType: responseContentType }),
253
+ });
254
+
255
+ const startTime = Date.now();
256
+ const result = await client.send(command);
257
+ const downloadTime = Date.now() - startTime;
258
+
259
+ // Log performance for large files
260
+ if (result.ContentLength > 10 * 1024 * 1024) {
261
+ console.log(`[mbkbucket] Large file download: ${keyToDownload}, Size: ${result.ContentLength} bytes, Time: ${downloadTime}ms`);
262
+ }
263
+
264
+ return {
265
+ ...result,
266
+ key: keyToDownload,
267
+ downloadedAt: new Date().toISOString(),
268
+ downloadTime
269
+ };
270
+ } catch (error) {
271
+ console.error(`Download failed for key ${key}:`, error);
272
+
273
+ // Some S3-compatible endpoints return 304 Not Modified as a response to conditional GETs.
274
+ // The SDK surface may throw an error with metadata httpStatusCode=304 — treat this as a not-modified condition.
275
+ if (error && error.$metadata && error.$metadata.httpStatusCode === 304) {
276
+ return { notModified: true, key: keyToDownload };
277
+ }
278
+
279
+ // Handle specific S3 errors
280
+ if (error.name === 'NoSuchKey') {
281
+ throw new Error(`File not found: ${key}`);
282
+ } else if (error.name === 'AccessDenied') {
283
+ throw new Error(`Access denied for file: ${key}`);
284
+ }
285
+
286
+ throw new Error(`Download failed: ${error.message}`);
287
+ }
288
+ }
289
+
290
+ // Delete file with enhanced error handling
291
+ export async function deleteFile(key, bucketName = 'r2') {
292
+ try {
293
+ if (!key) {
294
+ throw new Error('Key is required');
295
+ }
296
+ const keyToDelete = ensureKeyHasAppPrefix(key);
297
+
298
+ const client = getBucketClient(bucketName);
299
+ const config = getBucketConfig(bucketName);
300
+ const bucket = config.BUCKET_NAME;
301
+
302
+ const command = new DeleteObjectCommand({
303
+ Bucket: bucket,
304
+ Key: keyToDelete,
305
+ });
306
+
307
+ const result = await client.send(command);
308
+
309
+ return {
310
+ ...result,
311
+ key: keyToDelete,
312
+ deletedAt: new Date().toISOString()
313
+ };
314
+ } catch (error) {
315
+ console.error(`Delete failed:`, error);
316
+ throw new Error(`Delete failed: ${error.message}`);
317
+ }
318
+ }
319
+
320
+ // Batch delete multiple files
321
+ export async function deleteFiles(keys, bucketName = 'r2') {
322
+ try {
323
+ if (!keys || !Array.isArray(keys) || keys.length === 0) {
324
+ throw new Error('Keys array is required and must not be empty');
325
+ }
326
+
327
+ const client = getBucketClient(bucketName);
328
+ const config = getBucketConfig(bucketName);
329
+ const bucket = config.BUCKET_NAME;
330
+
331
+ // S3 allows max 1000 objects per delete request
332
+ const maxBatchSize = 1000;
333
+ const results = [];
334
+
335
+ for (let i = 0; i < keys.length; i += maxBatchSize) {
336
+ const batch = keys.slice(i, i + maxBatchSize).map(k => ({ Key: ensureKeyHasAppPrefix(k) }));
337
+
338
+ const command = new DeleteObjectsCommand({
339
+ Bucket: bucket,
340
+ Delete: {
341
+ Objects: batch,
342
+ Quiet: false
343
+ }
344
+ });
345
+
346
+ const result = await client.send(command);
347
+ results.push(result);
348
+ }
349
+
350
+ return {
351
+ results,
352
+ deletedCount: results.reduce((acc, result) => acc + (result.Deleted?.length || 0), 0),
353
+ errors: results.reduce((acc, result) => acc.concat(result.Errors || []), []),
354
+ deletedAt: new Date().toISOString()
355
+ };
356
+ } catch (error) {
357
+ console.error('Batch delete failed:', error);
358
+ throw new Error(`Batch delete failed: ${error.message}`);
359
+ }
360
+ }
361
+
362
+ // List files with enhanced pagination and filtering
363
+ export async function listfiles(prefix = '', options = {}) {
364
+ try {
365
+ const {
366
+ maxKeys = 1000,
367
+ continuationToken = null,
368
+ delimiter = null,
369
+ fetchOwner = false,
370
+ startAfter = null,
371
+ bucketName = 'r2'
372
+ } = options;
373
+
374
+ // Enforce non-root prefix: default to APP_NAME and normalize
375
+ const effectivePrefix = ensurePrefix(prefix);
376
+
377
+ const client = getBucketClient(bucketName);
378
+ const config = getBucketConfig(bucketName);
379
+ const bucket = config.BUCKET_NAME;
380
+
381
+ console.log(`[mbkbucket] Listing objects in bucket=${bucket} prefix=${effectivePrefix}`);
382
+
383
+ const command = new ListObjectsV2Command({
384
+ Bucket: bucket,
385
+ Prefix: effectivePrefix,
386
+ MaxKeys: maxKeys,
387
+ ...(continuationToken && { ContinuationToken: continuationToken }),
388
+ ...(delimiter && { Delimiter: delimiter }),
389
+ ...(fetchOwner && { FetchOwner: fetchOwner }),
390
+ ...(startAfter && { StartAfter: startAfter }),
391
+ });
392
+
393
+ const result = await client.send(command);
394
+
395
+ return {
396
+ ...result,
397
+ requestedAt: new Date().toISOString(),
398
+ totalFiles: result.KeyCount || 0,
399
+ hasMore: result.IsTruncated || false,
400
+ nextToken: result.NextContinuationToken || null
401
+ };
402
+ } catch (error) {
403
+ console.error('List files failed:', error);
404
+ throw new Error(`List files failed: ${error.message}`);
405
+ }
406
+ }
407
+
408
+ // Get file metadata without downloading
409
+ export async function getFileMetadata(key, bucketName = 'r2') {
410
+ try {
411
+ if (!key) {
412
+ throw new Error('Key is required');
413
+ }
414
+ const keyToCheck = ensureKeyHasAppPrefix(key);
415
+
416
+ const client = getBucketClient(bucketName);
417
+ const config = getBucketConfig(bucketName);
418
+ const bucket = config.BUCKET_NAME;
419
+
420
+ const command = new HeadObjectCommand({
421
+ Bucket: bucket,
422
+ Key: keyToCheck,
423
+ });
424
+
425
+ const result = await client.send(command);
426
+
427
+ return {
428
+ ...result,
429
+ key: keyToCheck,
430
+ exists: true,
431
+ queriedAt: new Date().toISOString()
432
+ };
433
+ } catch (error) {
434
+ if (error.name === 'NotFound' || error.name === 'NoSuchKey') {
435
+ return {
436
+ key,
437
+ exists: false,
438
+ queriedAt: new Date().toISOString()
439
+ };
440
+ }
441
+
442
+ console.error(`Get metadata failed for key ${key}:`, error);
443
+ throw new Error(`Get metadata failed: ${error.message}`);
444
+ }
445
+ }
446
+
447
+ // Check if file exists
448
+ export async function fileExists(key, bucketName = 'r2') {
449
+ try {
450
+ const metadata = await getFileMetadata(key, bucketName);
451
+ return metadata.exists;
452
+ } catch (error) {
453
+ return false;
454
+ }
455
+ }
456
+
457
+ // Get file size without downloading
458
+ export async function getFileSize(key, bucketName = 'r2') {
459
+ try {
460
+ const metadata = await getFileMetadata(key, bucketName);
461
+ return metadata.exists ? metadata.ContentLength : null;
462
+ } catch (error) {
463
+ return null;
464
+ }
465
+ }
466
+
467
+ // Export health check for external use
468
+ export { checkR2Health };
469
+
470
+ // Generate signed URL for temporary access
471
+ export async function generateSignedUrl(key, operation = 'getObject', expiresIn = 3600) {
472
+ try {
473
+ const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
474
+ const keyToUse = ensureKeyHasAppPrefix(key);
475
+
476
+ let command;
477
+ const cfg = getBucketConfig();
478
+ const bucketName = cfg.BUCKET_NAME;
479
+ switch (operation) {
480
+ case 'getObject':
481
+ command = new GetObjectCommand({ Bucket: bucketName, Key: keyToUse });
482
+ break;
483
+ case 'putObject':
484
+ command = new PutObjectCommand({ Bucket: bucketName, Key: keyToUse });
485
+ break;
486
+ default:
487
+ throw new Error(`Unsupported operation: ${operation}`);
488
+ }
489
+
490
+ const signedUrl = await getSignedUrl(bucketClient, command, { expiresIn });
491
+
492
+ return {
493
+ url: signedUrl,
494
+ key,
495
+ operation,
496
+ expiresIn,
497
+ expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
498
+ generatedAt: new Date().toISOString()
499
+ };
500
+ } catch (error) {
501
+ console.error(`Generate signed URL failed for key ${key}:`, error);
502
+ throw new Error(`Generate signed URL failed: ${error.message}`);
503
+ }
504
+ }
package/package.json CHANGED
@@ -1,39 +1,51 @@
1
1
  {
2
2
  "name": "mbkbucket",
3
- "version": "1.0.2",
4
- "description": "Bucket manager for mbkauthe apps",
3
+ "version": "1.1.1",
4
+ "description": "S3 Bucket manager for mbktech.org apps",
5
+ "author": "Muhammad Bin Khalid",
6
+ "homepage": "https://github.com/MIbnEKhalid/mbkbucket#readme",
7
+ "license": "GPL-2.0",
5
8
  "main": "index.js",
6
9
  "type": "module",
10
+ "types": "index.d.ts",
7
11
  "scripts": {
8
- "dev": "nodemon index.js",
12
+ "dev": "cross-env mbkbucket_test=dev nodemon index.js",
9
13
  "test": "echo \\\"Error: no test specified\\\" && exit 1"
10
14
  },
15
+ "dependencies": {
16
+ "@aws-sdk/client-s3": "^3.968.0",
17
+ "compression": "^1.8.0",
18
+ "dotenv": "^16.6.1",
19
+ "express": "^4.22.1",
20
+ "express-handlebars": "^8.0.3",
21
+ "express-rate-limit": "^7.0.1",
22
+ "mbkauthe": "^4.3.0",
23
+ "multer": "^2.0.2",
24
+ "pg": "^8.16.3"
25
+ },
26
+ "devDependencies": {
27
+ "nodemon": "^2.0.22",
28
+ "cross-env": "^7.0.3"
29
+ },
11
30
  "repository": {
12
31
  "type": "git",
13
32
  "url": "git+https://github.com/MIbnEKhalid/mbkbucket.git"
14
33
  },
34
+ "bugs": {
35
+ "url": "https://github.com/MIbnEKhalid/mbkbucket/issues"
36
+ },
15
37
  "keywords": [
38
+ "mbkbucket",
16
39
  "s3-bucket",
17
40
  "s3",
18
41
  "bucket",
19
42
  "r2-bucket",
20
43
  "bucket-manager",
21
- "manager",
22
44
  "aws-sdk",
23
45
  "cloudflare-r2",
24
- "storage"
25
- ],
26
- "author": "Muhammad Bin Khalid",
27
- "license": "GPL-2.0",
28
- "bugs": {
29
- "url": "https://github.com/MIbnEKhalid/mbkbucket/issues"
30
- },
31
- "homepage": "https://github.com/MIbnEKhalid/mbkbucket#readme",
32
- "dependencies": {
33
- "mbkauthe": "latest",
34
- "module": "^2.0.0"
35
- },
36
- "devDependencies": {
37
- "nodemon": "^3.1.11"
38
- }
39
- }
46
+ "idrivee2",
47
+ "storage",
48
+ "mbkauthe",
49
+ "mbktech.org"
50
+ ]
51
+ }