mbkbucket 4.0.1 → 4.2.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/.env.example +3 -2
- package/README.md +96 -44
- package/index.js +4 -1
- package/lib/cli.js +1 -1
- package/lib/public/bucketadmin.js +255 -33
- package/lib/public/mbkbucket-helper-demo.html +0 -58
- package/lib/public/mbkbucket-helper.js +1 -9
- package/lib/src/app.js +10 -24
- package/lib/src/config/validation.js +6 -1
- package/lib/src/controllers/api.controller.js +34 -6
- package/lib/src/controllers/dashboard.controller.js +2 -2
- package/lib/src/controllers/info.controller.js +15 -14
- package/lib/src/controllers/view.controller.js +24 -22
- package/lib/src/core/metrics.js +134 -0
- package/lib/src/core/storage/capabilities.js +58 -0
- package/lib/src/core/storage/errors.js +0 -5
- package/lib/src/core/storage/gdrive-auth.js +0 -36
- package/lib/src/core/storage/gdrive-provider.js +103 -44
- package/lib/src/core/storage/index.js +4 -1
- package/lib/src/core/storage/local-provider.js +508 -0
- package/lib/src/core/storage/models.js +91 -50
- package/lib/src/core/storage/path-strategy.js +22 -2
- package/lib/src/core/storage/s3-provider.js +19 -38
- package/lib/src/core/storage/storage-manager.js +76 -53
- package/lib/src/core/storage/storage-provider.js +49 -120
- package/lib/src/middleware/error-handler.js +15 -52
- package/lib/src/middleware/rate-limiter.js +1 -1
- package/lib/src/routes/api.routes.js +4 -1
- package/lib/src/services/s3.service.js +2 -2
- package/lib/src/services/storage.service.js +118 -20
- package/lib/src/utils/cleanup-scheduler.js +91 -0
- package/lib/src/utils/helpers.js +8 -51
- package/package.json +4 -4
- package/views/bucket.hbs +0 -5
- package/views/bucketadmin_preview_modal.hbs +3 -3
|
@@ -6,13 +6,33 @@
|
|
|
6
6
|
|
|
7
7
|
import { mbkautheVar } from "mbkauthe";
|
|
8
8
|
import { StorageValidationError } from "./errors.js";
|
|
9
|
-
import { trimLeadingSlashes, trimSlashes } from "
|
|
9
|
+
import { trimLeadingSlashes, trimSlashes } from "mbkutils";
|
|
10
10
|
|
|
11
11
|
const escapeRegExp = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
12
12
|
|
|
13
|
+
let _configuredAppName = null;
|
|
14
|
+
|
|
13
15
|
export class PathStrategy {
|
|
16
|
+
static setAppName(name) {
|
|
17
|
+
_configuredAppName = name ? String(name).trim() : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static resetAppName() {
|
|
21
|
+
_configuredAppName = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
static getAppName() {
|
|
15
|
-
|
|
25
|
+
if (_configuredAppName !== null) {
|
|
26
|
+
return _configuredAppName;
|
|
27
|
+
}
|
|
28
|
+
let envAppName = null;
|
|
29
|
+
if (process.env.mbkautheVar) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = typeof process.env.mbkautheVar === 'string' ? JSON.parse(process.env.mbkautheVar) : process.env.mbkautheVar;
|
|
32
|
+
if (parsed?.APP_NAME) envAppName = parsed.APP_NAME;
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
const app = envAppName || process.env.MBKAUTHE_APP_NAME || process.env.APP_NAME || mbkautheVar?.APP_NAME;
|
|
16
36
|
return typeof app === 'string' && app.trim() ? app.trim() : '';
|
|
17
37
|
}
|
|
18
38
|
|
|
@@ -6,18 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
import { S3Client, ListObjectsV2Command, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, HeadObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, ListMultipartUploadsCommand, CopyObjectCommand } from '@aws-sdk/client-s3';
|
|
8
8
|
import { StorageProvider } from './storage-provider.js';
|
|
9
|
+
import { StorageCapabilities } from './capabilities.js';
|
|
9
10
|
import { StorageFile, StorageFolder, StorageListResult } from './models.js';
|
|
10
11
|
import { StorageNotFoundError, StorageConflictError, StorageAccessDeniedError, StorageValidationError, StorageError } from './errors.js';
|
|
11
12
|
import { createLogger } from "#logger";
|
|
12
|
-
import { nowIso
|
|
13
|
+
import { nowIso } from "#helpers";
|
|
14
|
+
import { trimSlashes } from "mbkutils";
|
|
13
15
|
|
|
14
16
|
const debugS3 = createLogger('s3-provider');
|
|
15
17
|
|
|
16
18
|
export class S3StorageProvider extends StorageProvider {
|
|
17
|
-
/**
|
|
18
|
-
* @param {string} name - Connection name
|
|
19
|
-
* @param {object} config - S3 configuration (BUCKET_NAME, ACCESS_KEY_ID, SECRET_ACCESS_KEY, ENDPOINT, region)
|
|
20
|
-
*/
|
|
21
19
|
constructor(name, config = {}) {
|
|
22
20
|
super(name, config);
|
|
23
21
|
this.bucket = config.BUCKET_NAME;
|
|
@@ -49,16 +47,19 @@ export class S3StorageProvider extends StorageProvider {
|
|
|
49
47
|
}
|
|
50
48
|
|
|
51
49
|
get capabilities() {
|
|
52
|
-
return {
|
|
50
|
+
return new StorageCapabilities({
|
|
53
51
|
multipart: true,
|
|
54
|
-
|
|
52
|
+
resumable: true,
|
|
55
53
|
nativeFolders: false,
|
|
56
54
|
ranges: true,
|
|
57
55
|
copy: true,
|
|
58
56
|
move: true,
|
|
59
57
|
search: false,
|
|
60
|
-
directDownloadUrl: false
|
|
61
|
-
|
|
58
|
+
directDownloadUrl: false,
|
|
59
|
+
versioning: false,
|
|
60
|
+
publicUrls: false,
|
|
61
|
+
metadata: true
|
|
62
|
+
});
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
async checkHealth() {
|
|
@@ -279,7 +280,8 @@ export class S3StorageProvider extends StorageProvider {
|
|
|
279
280
|
size: item.Size,
|
|
280
281
|
lastModified: item.LastModified,
|
|
281
282
|
etag: item.ETag,
|
|
282
|
-
storageClass: item.StorageClass || 'STANDARD'
|
|
283
|
+
storageClass: item.StorageClass || 'STANDARD',
|
|
284
|
+
provider: 's3'
|
|
283
285
|
}));
|
|
284
286
|
|
|
285
287
|
const commonPrefixes = (result.CommonPrefixes || []).map(p => new StorageFolder(p.Prefix));
|
|
@@ -340,6 +342,7 @@ export class S3StorageProvider extends StorageProvider {
|
|
|
340
342
|
}
|
|
341
343
|
|
|
342
344
|
async copyFile(sourceKey, destKey, options = {}) {
|
|
345
|
+
this.assertCapability('copy', 'copyFile');
|
|
343
346
|
try {
|
|
344
347
|
const copySource = encodeURIComponent(`${this.bucket}/${sourceKey}`);
|
|
345
348
|
await this.client.send(new CopyObjectCommand({
|
|
@@ -357,39 +360,12 @@ export class S3StorageProvider extends StorageProvider {
|
|
|
357
360
|
}
|
|
358
361
|
}
|
|
359
362
|
|
|
360
|
-
async generateSignedUrl(key, operation = 'getObject', expiresIn = 3600, _options = {}) {
|
|
361
|
-
try {
|
|
362
|
-
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
|
|
363
|
-
const command = operation === 'getObject'
|
|
364
|
-
? new GetObjectCommand({ Bucket: this.bucket, Key: key })
|
|
365
|
-
: operation === 'putObject'
|
|
366
|
-
? new PutObjectCommand({ Bucket: this.bucket, Key: key })
|
|
367
|
-
: null;
|
|
368
|
-
|
|
369
|
-
if (!command) throw new StorageValidationError(`Unsupported operation: ${operation}`);
|
|
370
|
-
|
|
371
|
-
const signedUrl = await getSignedUrl(this.client, command, { expiresIn });
|
|
372
|
-
const now = Date.now();
|
|
373
|
-
return {
|
|
374
|
-
url: signedUrl,
|
|
375
|
-
key,
|
|
376
|
-
operation,
|
|
377
|
-
expiresIn,
|
|
378
|
-
expiresAt: new Date(now + expiresIn * 1000).toISOString(),
|
|
379
|
-
generatedAt: new Date(now).toISOString()
|
|
380
|
-
};
|
|
381
|
-
} catch (error) {
|
|
382
|
-
debugS3('Generate signed URL failed for key %s: %s', key, error.message);
|
|
383
|
-
if (error instanceof StorageError) throw error;
|
|
384
|
-
throw new StorageError(`Generate signed URL failed: ${error.message}`);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
363
|
// ---------------------------------------------------------------------------
|
|
389
364
|
// S3 Multipart Upload Operations
|
|
390
365
|
// ---------------------------------------------------------------------------
|
|
391
366
|
|
|
392
367
|
async createMultipartUpload(key, contentType = 'application/octet-stream', metadata = {}) {
|
|
368
|
+
this.assertCapability('multipart', 'createMultipartUpload');
|
|
393
369
|
const result = await this.client.send(new CreateMultipartUploadCommand({
|
|
394
370
|
Bucket: this.bucket,
|
|
395
371
|
Key: key,
|
|
@@ -404,6 +380,7 @@ export class S3StorageProvider extends StorageProvider {
|
|
|
404
380
|
}
|
|
405
381
|
|
|
406
382
|
async uploadPart(key, uploadId, partNumber, buffer) {
|
|
383
|
+
this.assertCapability('multipart', 'uploadPart');
|
|
407
384
|
const result = await this.client.send(new UploadPartCommand({
|
|
408
385
|
Bucket: this.bucket,
|
|
409
386
|
Key: key,
|
|
@@ -416,6 +393,7 @@ export class S3StorageProvider extends StorageProvider {
|
|
|
416
393
|
}
|
|
417
394
|
|
|
418
395
|
async completeMultipartUpload(key, uploadId, parts) {
|
|
396
|
+
this.assertCapability('multipart', 'completeMultipartUpload');
|
|
419
397
|
const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber);
|
|
420
398
|
await this.client.send(new CompleteMultipartUploadCommand({
|
|
421
399
|
Bucket: this.bucket,
|
|
@@ -429,6 +407,7 @@ export class S3StorageProvider extends StorageProvider {
|
|
|
429
407
|
}
|
|
430
408
|
|
|
431
409
|
async abortMultipartUpload(key, uploadId) {
|
|
410
|
+
this.assertCapability('multipart', 'abortMultipartUpload');
|
|
432
411
|
await this.client.send(new AbortMultipartUploadCommand({
|
|
433
412
|
Bucket: this.bucket,
|
|
434
413
|
Key: key,
|
|
@@ -438,6 +417,7 @@ export class S3StorageProvider extends StorageProvider {
|
|
|
438
417
|
}
|
|
439
418
|
|
|
440
419
|
async listIncompleteMultipartUploads(prefix = '') {
|
|
420
|
+
this.assertCapability('multipart', 'listIncompleteMultipartUploads');
|
|
441
421
|
try {
|
|
442
422
|
const result = await this.client.send(new ListMultipartUploadsCommand({
|
|
443
423
|
Bucket: this.bucket,
|
|
@@ -452,6 +432,7 @@ export class S3StorageProvider extends StorageProvider {
|
|
|
452
432
|
}
|
|
453
433
|
|
|
454
434
|
async cleanupIncompleteMultipartUploads(olderThanDays = 7, prefix = '') {
|
|
435
|
+
this.assertCapability('multipart', 'cleanupIncompleteMultipartUploads');
|
|
455
436
|
try {
|
|
456
437
|
const uploads = await this.listIncompleteMultipartUploads(prefix);
|
|
457
438
|
if (!uploads.length) {
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MBKBucket - Storage Manager & Provider Registry
|
|
2
|
+
* MBKBucket - Storage Manager & Multi-Provider Connection Registry
|
|
3
3
|
* Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
|
|
4
4
|
* Licensed under the MIT License.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { EventEmitter } from "events";
|
|
7
8
|
import dotenv from "dotenv";
|
|
8
9
|
import { mbkautheVar } from "mbkauthe";
|
|
9
10
|
import { S3StorageProvider } from "./s3-provider.js";
|
|
10
11
|
import { GoogleDriveStorageProvider } from "./gdrive-provider.js";
|
|
12
|
+
import { LocalStorageProvider } from "./local-provider.js";
|
|
11
13
|
import { StorageConfigError, StorageValidationError } from "./errors.js";
|
|
12
14
|
import { createLogger } from "#logger";
|
|
13
15
|
|
|
@@ -24,29 +26,36 @@ function parseJsonEnv(envVar, fallback = {}) {
|
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
export class StorageManager {
|
|
29
|
+
export class StorageManager extends EventEmitter {
|
|
28
30
|
constructor() {
|
|
31
|
+
super();
|
|
29
32
|
this._providers = new Map();
|
|
30
33
|
this._factories = new Map();
|
|
31
34
|
this._configs = new Map();
|
|
35
|
+
this._defaultConnectionName = null;
|
|
32
36
|
|
|
33
|
-
// Register built-in provider drivers
|
|
34
37
|
this.registerDriver('s3', (name, cfg) => new S3StorageProvider(name, cfg));
|
|
35
38
|
this.registerDriver('r2', (name, cfg) => new S3StorageProvider(name, cfg));
|
|
36
39
|
this.registerDriver('minio', (name, cfg) => new S3StorageProvider(name, cfg));
|
|
40
|
+
this.registerDriver('idrive', (name, cfg) => new S3StorageProvider(name, cfg));
|
|
37
41
|
this.registerDriver('gdrive', (name, cfg) => new GoogleDriveStorageProvider(name, cfg));
|
|
38
42
|
this.registerDriver('google-drive', (name, cfg) => new GoogleDriveStorageProvider(name, cfg));
|
|
39
43
|
this.registerDriver('googledrive', (name, cfg) => new GoogleDriveStorageProvider(name, cfg));
|
|
44
|
+
this.registerDriver('local', (name, cfg) => new LocalStorageProvider(name, cfg));
|
|
45
|
+
this.registerDriver('fs', (name, cfg) => new LocalStorageProvider(name, cfg));
|
|
46
|
+
this.registerDriver('filesystem', (name, cfg) => new LocalStorageProvider(name, cfg));
|
|
40
47
|
|
|
41
48
|
this.reloadFromEnv();
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
* Register a custom provider driver.
|
|
46
|
-
* @param {string} type
|
|
47
|
-
* @param {(name: string, config: object) => import('./storage-provider.js').StorageProvider} factory
|
|
48
|
-
*/
|
|
51
|
+
|
|
49
52
|
registerDriver(type, factory) {
|
|
53
|
+
if (!type || typeof type !== 'string') {
|
|
54
|
+
throw new StorageValidationError("Driver type must be a non-empty string");
|
|
55
|
+
}
|
|
56
|
+
if (typeof factory !== 'function') {
|
|
57
|
+
throw new StorageValidationError("Driver factory must be a function");
|
|
58
|
+
}
|
|
50
59
|
this._factories.set(String(type).toLowerCase(), factory);
|
|
51
60
|
}
|
|
52
61
|
|
|
@@ -54,9 +63,14 @@ export class StorageManager {
|
|
|
54
63
|
this.registerDriver(type, factory);
|
|
55
64
|
}
|
|
56
65
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
hasDriver(type) {
|
|
67
|
+
return this._factories.has(String(type).toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getDriverTypes() {
|
|
71
|
+
return Array.from(this._factories.keys());
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
reloadFromEnv() {
|
|
61
75
|
this._providers.clear();
|
|
62
76
|
this._configs.clear();
|
|
@@ -71,31 +85,39 @@ export class StorageManager {
|
|
|
71
85
|
}
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
/**
|
|
75
|
-
* Register a named storage connection.
|
|
76
|
-
* @param {string} name
|
|
77
|
-
* @param {object} config
|
|
78
|
-
*/
|
|
79
88
|
registerConnection(name, config) {
|
|
80
|
-
const cleanName = String(name).trim();
|
|
89
|
+
const cleanName = String(name || '').trim();
|
|
81
90
|
if (!cleanName) throw new StorageValidationError("Connection name cannot be empty");
|
|
91
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
92
|
+
throw new StorageValidationError(`Connection configuration for '${cleanName}' must be an object`);
|
|
93
|
+
}
|
|
94
|
+
|
|
82
95
|
this._configs.set(cleanName, Object.freeze({ ...config }));
|
|
83
96
|
this._providers.delete(cleanName);
|
|
84
97
|
}
|
|
85
98
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
99
|
+
unregisterConnection(name) {
|
|
100
|
+
const cleanName = String(name || '').trim();
|
|
101
|
+
this._configs.delete(cleanName);
|
|
102
|
+
this._providers.delete(cleanName);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
hasConnection(name) {
|
|
106
|
+
return this._configs.has(String(name || '').trim());
|
|
107
|
+
}
|
|
108
|
+
|
|
90
109
|
getAvailableConnectionNames() {
|
|
91
110
|
return Array.from(this._configs.keys());
|
|
92
111
|
}
|
|
93
112
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
113
|
+
setDefaultConnectionName(name) {
|
|
114
|
+
this._defaultConnectionName = name ? String(name).trim() : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
98
117
|
getDefaultConnectionName() {
|
|
118
|
+
if (this._defaultConnectionName && this._configs.has(this._defaultConnectionName)) {
|
|
119
|
+
return this._defaultConnectionName;
|
|
120
|
+
}
|
|
99
121
|
const configuredDefault = mbkautheVar?.bucket;
|
|
100
122
|
if (configuredDefault && this._configs.has(configuredDefault)) {
|
|
101
123
|
return configuredDefault;
|
|
@@ -104,57 +126,61 @@ export class StorageManager {
|
|
|
104
126
|
return available.length > 0 ? available[0] : null;
|
|
105
127
|
}
|
|
106
128
|
|
|
107
|
-
/**
|
|
108
|
-
* Resolve a candidate connection name or fall back to default.
|
|
109
|
-
* @param {string} [name]
|
|
110
|
-
* @returns {string}
|
|
111
|
-
*/
|
|
112
129
|
resolveConnectionName(name) {
|
|
113
130
|
const raw = typeof name === 'string' ? name.trim() : name;
|
|
114
131
|
const candidate = !raw ? this.getDefaultConnectionName() : String(raw);
|
|
115
132
|
|
|
116
133
|
const available = this.getAvailableConnectionNames();
|
|
117
134
|
if (!candidate) {
|
|
135
|
+
if (available.length === 0) {
|
|
136
|
+
throw new StorageConfigError('BucketConnection environment variable is not set or empty. Please configure it with your storage connections.');
|
|
137
|
+
}
|
|
118
138
|
throw new StorageValidationError('No storage connection selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.');
|
|
119
139
|
}
|
|
140
|
+
if (!this._configs.has(candidate)) {
|
|
141
|
+
throw new StorageValidationError(`Storage connection '${candidate}' not found in BucketConnection. Available connections: ${available.join(', ') || 'none'}`);
|
|
142
|
+
}
|
|
120
143
|
if (available.length === 0) {
|
|
121
144
|
throw new StorageConfigError('BucketConnection environment variable is not set or empty. Please configure it with your storage connections.');
|
|
122
145
|
}
|
|
123
|
-
if (!this._configs.has(candidate)) {
|
|
124
|
-
throw new StorageValidationError(`Storage connection '${candidate}' not found in BucketConnection. Available connections: ${available.join(', ')}`);
|
|
125
|
-
}
|
|
126
146
|
return candidate;
|
|
127
147
|
}
|
|
128
148
|
|
|
129
|
-
/**
|
|
130
|
-
* Get connection configuration object.
|
|
131
|
-
* @param {string} [name]
|
|
132
|
-
* @returns {object}
|
|
133
|
-
*/
|
|
134
149
|
getConnectionConfig(name) {
|
|
135
150
|
const resolved = this.resolveConnectionName(name);
|
|
136
151
|
return this._configs.get(resolved);
|
|
137
152
|
}
|
|
138
153
|
|
|
139
|
-
/**
|
|
140
|
-
* Determine the provider type from configuration.
|
|
141
|
-
* @param {object} config
|
|
142
|
-
* @returns {string}
|
|
143
|
-
*/
|
|
144
154
|
inferProviderType(config = {}) {
|
|
145
155
|
if (config?.type) return String(config.type).toLowerCase();
|
|
146
156
|
if (config?.provider) return String(config.provider).toLowerCase();
|
|
157
|
+
if (config?.basePath || config?.rootPath || config?.directory) {
|
|
158
|
+
return 'local';
|
|
159
|
+
}
|
|
147
160
|
if (config?.client_id || config?.refresh_token || config?.service_account_key || config?.folder_id || config?.service_account_email) {
|
|
148
161
|
return 'gdrive';
|
|
149
162
|
}
|
|
150
163
|
return 's3';
|
|
151
164
|
}
|
|
152
165
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
166
|
+
clearCache() {
|
|
167
|
+
this._providers.clear();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async dispose() {
|
|
171
|
+
for (const [name, provider] of this._providers.entries()) {
|
|
172
|
+
try {
|
|
173
|
+
if (typeof provider.dispose === 'function') {
|
|
174
|
+
await provider.dispose();
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
debugStorage('Error disposing provider "%s": %s', name, err.message);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
this._providers.clear();
|
|
181
|
+
this.removeAllListeners();
|
|
182
|
+
}
|
|
183
|
+
|
|
158
184
|
getProvider(name) {
|
|
159
185
|
const resolved = this.resolveConnectionName(name);
|
|
160
186
|
if (this._providers.has(resolved)) {
|
|
@@ -172,14 +198,10 @@ export class StorageManager {
|
|
|
172
198
|
debugStorage('Instantiating provider "%s" (type: %s)', resolved, providerType);
|
|
173
199
|
const provider = factory(resolved, config);
|
|
174
200
|
this._providers.set(resolved, provider);
|
|
201
|
+
this.emit('provider:instantiated', { name: resolved, type: providerType });
|
|
175
202
|
return provider;
|
|
176
203
|
}
|
|
177
204
|
|
|
178
|
-
/**
|
|
179
|
-
* Check health of the default or specified connection.
|
|
180
|
-
* @param {string} [name]
|
|
181
|
-
* @returns {Promise<object>}
|
|
182
|
-
*/
|
|
183
205
|
async checkHealth(name) {
|
|
184
206
|
try {
|
|
185
207
|
const provider = this.getProvider(name);
|
|
@@ -201,3 +223,4 @@ export class StorageManager {
|
|
|
201
223
|
}
|
|
202
224
|
|
|
203
225
|
export const storageManager = new StorageManager();
|
|
226
|
+
|
|
@@ -5,148 +5,98 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { StorageUnsupportedOperationError } from "./errors.js";
|
|
8
|
+
import { StorageCapabilities } from "./capabilities.js";
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
-
* Abstract base class for storage providers (S3, Cloudflare R2, Google Drive, Azure, etc.)
|
|
11
|
-
*/
|
|
12
10
|
export class StorageProvider {
|
|
13
|
-
/**
|
|
14
|
-
* @param {string} name - Connection/Bucket name identifier
|
|
15
|
-
* @param {object} config - Provider-specific configuration
|
|
16
|
-
*/
|
|
17
11
|
constructor(name, config = {}) {
|
|
18
12
|
if (new.target === StorageProvider) {
|
|
19
13
|
throw new TypeError("Cannot construct StorageProvider instances directly");
|
|
20
14
|
}
|
|
21
|
-
this.name = name;
|
|
15
|
+
this.name = String(name);
|
|
22
16
|
this.config = Object.freeze({ ...config });
|
|
17
|
+
this.rootPrefix = config.rootPrefix || config.basePath || '';
|
|
23
18
|
}
|
|
24
19
|
|
|
25
|
-
/**
|
|
26
|
-
* Provider type identifier (e.g. 's3', 'gdrive', 'r2')
|
|
27
|
-
* @returns {string}
|
|
28
|
-
*/
|
|
29
20
|
get type() {
|
|
30
21
|
return 'generic';
|
|
31
22
|
}
|
|
32
23
|
|
|
33
|
-
/**
|
|
34
|
-
* Explicit provider capabilities.
|
|
35
|
-
* @returns {object}
|
|
36
|
-
*/
|
|
37
24
|
get capabilities() {
|
|
38
|
-
return {
|
|
25
|
+
return new StorageCapabilities({
|
|
39
26
|
multipart: false,
|
|
40
|
-
presignedUrls: false,
|
|
41
27
|
nativeFolders: false,
|
|
42
28
|
ranges: true,
|
|
43
29
|
copy: false,
|
|
44
30
|
move: false,
|
|
45
31
|
search: false,
|
|
46
|
-
directDownloadUrl: false
|
|
47
|
-
|
|
32
|
+
directDownloadUrl: false,
|
|
33
|
+
versioning: false,
|
|
34
|
+
publicUrls: false,
|
|
35
|
+
metadata: true
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
supports(capabilityName) {
|
|
40
|
+
return this.capabilities.has(capabilityName);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
assertCapability(capabilityName, operationName) {
|
|
44
|
+
this.capabilities.assert(capabilityName, operationName, `provider '${this.type}'`);
|
|
48
45
|
}
|
|
49
46
|
|
|
50
|
-
/**
|
|
51
|
-
* List files and folders at the given prefix/path.
|
|
52
|
-
* @param {string} [prefix='']
|
|
53
|
-
* @param {object} [options={}]
|
|
54
|
-
* @returns {Promise<import('./models.js').StorageListResult>}
|
|
55
|
-
*/
|
|
56
47
|
async listFiles(prefix = '', options = {}) {
|
|
57
48
|
throw new StorageUnsupportedOperationError(`listFiles() not implemented by ${this.constructor.name}`);
|
|
58
49
|
}
|
|
59
50
|
|
|
60
|
-
/**
|
|
61
|
-
* Upload a file.
|
|
62
|
-
* @param {string} key
|
|
63
|
-
* @param {Buffer|Uint8Array|import('node:stream').Readable} fileBuffer
|
|
64
|
-
* @param {string} contentType
|
|
65
|
-
* @param {object} [options={}]
|
|
66
|
-
* @returns {Promise<{ key: string, fileSize: number, contentType: string, uploadedAt: string, [key: string]: any }>}
|
|
67
|
-
*/
|
|
68
51
|
async uploadFile(key, fileBuffer, contentType, options = {}) {
|
|
69
52
|
throw new StorageUnsupportedOperationError(`uploadFile() not implemented by ${this.constructor.name}`);
|
|
70
53
|
}
|
|
71
54
|
|
|
72
|
-
/**
|
|
73
|
-
* Download a file.
|
|
74
|
-
* @param {string} key
|
|
75
|
-
* @param {object} [options={}]
|
|
76
|
-
* @returns {Promise<{ Body: import('node:stream').Readable, ContentType?: string, ContentLength?: number, ETag?: string, LastModified?: Date, notModified?: boolean, key: string }>}
|
|
77
|
-
*/
|
|
78
55
|
async downloadFile(key, options = {}) {
|
|
79
56
|
throw new StorageUnsupportedOperationError(`downloadFile() not implemented by ${this.constructor.name}`);
|
|
80
57
|
}
|
|
81
58
|
|
|
82
|
-
/**
|
|
83
|
-
* Delete a single file.
|
|
84
|
-
* @param {string} key
|
|
85
|
-
* @param {object} [options={}]
|
|
86
|
-
* @returns {Promise<{ key: string, deletedAt: string, [key: string]: any }>}
|
|
87
|
-
*/
|
|
88
59
|
async deleteFile(key, options = {}) {
|
|
89
60
|
throw new StorageUnsupportedOperationError(`deleteFile() not implemented by ${this.constructor.name}`);
|
|
90
61
|
}
|
|
91
62
|
|
|
92
|
-
/**
|
|
93
|
-
* Delete multiple files.
|
|
94
|
-
* @param {string[]} keys
|
|
95
|
-
* @param {object} [options={}]
|
|
96
|
-
* @returns {Promise<{ results: any[], deletedCount: number, errors: any[], deletedAt: string }>}
|
|
97
|
-
*/
|
|
98
63
|
async deleteFiles(keys, options = {}) {
|
|
64
|
+
const concurrency = Math.max(1, Math.min(Number(options.concurrency) || 8, 32));
|
|
99
65
|
let deletedCount = 0;
|
|
100
66
|
const errors = [];
|
|
101
67
|
const results = [];
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < keys.length; i += concurrency) {
|
|
70
|
+
const batch = keys.slice(i, i + concurrency);
|
|
71
|
+
const batchResults = await Promise.allSettled(
|
|
72
|
+
batch.map(key => this.deleteFile(key, options))
|
|
73
|
+
);
|
|
74
|
+
batchResults.forEach((outcome, idx) => {
|
|
75
|
+
const key = batch[idx];
|
|
76
|
+
if (outcome.status === 'fulfilled') {
|
|
77
|
+
results.push(outcome.value);
|
|
78
|
+
deletedCount++;
|
|
79
|
+
} else {
|
|
80
|
+
errors.push({ key, error: outcome.reason?.message || 'Delete failed' });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
110
83
|
}
|
|
84
|
+
|
|
111
85
|
return { results, deletedCount, errors, deletedAt: new Date().toISOString() };
|
|
112
86
|
}
|
|
113
87
|
|
|
114
|
-
/**
|
|
115
|
-
* Delete a folder and its contents.
|
|
116
|
-
* @param {string} prefix
|
|
117
|
-
* @param {object} [options={}]
|
|
118
|
-
* @returns {Promise<{ deletedCount: number, prefix: string, deletedAt: string }>}
|
|
119
|
-
*/
|
|
120
88
|
async deleteFolder(prefix, options = {}) {
|
|
121
89
|
throw new StorageUnsupportedOperationError(`deleteFolder() not implemented by ${this.constructor.name}`);
|
|
122
90
|
}
|
|
123
91
|
|
|
124
|
-
/**
|
|
125
|
-
* Create a folder.
|
|
126
|
-
* @param {string} key
|
|
127
|
-
* @param {object} [options={}]
|
|
128
|
-
* @returns {Promise<{ key: string, success: boolean, [key: string]: any }>}
|
|
129
|
-
*/
|
|
130
92
|
async createFolder(key, options = {}) {
|
|
131
93
|
throw new StorageUnsupportedOperationError(`createFolder() not implemented by ${this.constructor.name}`);
|
|
132
94
|
}
|
|
133
95
|
|
|
134
|
-
/**
|
|
135
|
-
* Get file metadata.
|
|
136
|
-
* @param {string} key
|
|
137
|
-
* @param {object} [options={}]
|
|
138
|
-
* @returns {Promise<{ key: string, exists: boolean, ContentLength?: number, ContentType?: string, LastModified?: Date, ETag?: string, Metadata?: object, queriedAt: string }>}
|
|
139
|
-
*/
|
|
140
96
|
async getFileMetadata(key, options = {}) {
|
|
141
97
|
throw new StorageUnsupportedOperationError(`getFileMetadata() not implemented by ${this.constructor.name}`);
|
|
142
98
|
}
|
|
143
99
|
|
|
144
|
-
/**
|
|
145
|
-
* Check if a file exists.
|
|
146
|
-
* @param {string} key
|
|
147
|
-
* @param {object} [options={}]
|
|
148
|
-
* @returns {Promise<boolean>}
|
|
149
|
-
*/
|
|
150
100
|
async fileExists(key, options = {}) {
|
|
151
101
|
try {
|
|
152
102
|
const meta = await this.getFileMetadata(key, options);
|
|
@@ -156,12 +106,6 @@ export class StorageProvider {
|
|
|
156
106
|
}
|
|
157
107
|
}
|
|
158
108
|
|
|
159
|
-
/**
|
|
160
|
-
* Get file size in bytes.
|
|
161
|
-
* @param {string} key
|
|
162
|
-
* @param {object} [options={}]
|
|
163
|
-
* @returns {Promise<number|null>}
|
|
164
|
-
*/
|
|
165
109
|
async getFileSize(key, options = {}) {
|
|
166
110
|
try {
|
|
167
111
|
const meta = await this.getFileMetadata(key, options);
|
|
@@ -171,47 +115,32 @@ export class StorageProvider {
|
|
|
171
115
|
}
|
|
172
116
|
}
|
|
173
117
|
|
|
174
|
-
/**
|
|
175
|
-
* Generate a signed or temporary access URL if supported.
|
|
176
|
-
* @param {string} key
|
|
177
|
-
* @param {'getObject'|'putObject'} [operation='getObject']
|
|
178
|
-
* @param {number} [expiresIn=3600]
|
|
179
|
-
* @param {object} [options={}]
|
|
180
|
-
* @returns {Promise<{ url: string, key: string, operation: string, expiresIn: number, expiresAt: string, generatedAt: string }>}
|
|
181
|
-
*/
|
|
182
|
-
async generateSignedUrl(key, operation = 'getObject', expiresIn = 3600, options = {}) {
|
|
183
|
-
throw new StorageUnsupportedOperationError(`generateSignedUrl() is not supported by provider ${this.type}`);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Copy a file.
|
|
188
|
-
* @param {string} sourceKey
|
|
189
|
-
* @param {string} destKey
|
|
190
|
-
* @param {object} [options={}]
|
|
191
|
-
* @returns {Promise<{ sourceKey: string, destKey: string, copiedAt: string }>}
|
|
192
|
-
*/
|
|
193
118
|
async copyFile(sourceKey, destKey, options = {}) {
|
|
119
|
+
this.assertCapability('copy', 'copyFile');
|
|
194
120
|
throw new StorageUnsupportedOperationError(`copyFile() is not supported by provider ${this.type}`);
|
|
195
121
|
}
|
|
196
122
|
|
|
197
|
-
/**
|
|
198
|
-
* Move a file.
|
|
199
|
-
* @param {string} sourceKey
|
|
200
|
-
* @param {string} destKey
|
|
201
|
-
* @param {object} [options={}]
|
|
202
|
-
* @returns {Promise<{ sourceKey: string, destKey: string, movedAt: string }>}
|
|
203
|
-
*/
|
|
204
123
|
async moveFile(sourceKey, destKey, options = {}) {
|
|
124
|
+
this.assertCapability('move', 'moveFile');
|
|
205
125
|
await this.copyFile(sourceKey, destKey, options);
|
|
206
126
|
await this.deleteFile(sourceKey, options);
|
|
207
127
|
return { sourceKey, destKey, movedAt: new Date().toISOString() };
|
|
208
128
|
}
|
|
209
129
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
130
|
+
async initialize() {
|
|
131
|
+
return this;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async dispose() {
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async ping() {
|
|
139
|
+
return this.checkHealth();
|
|
140
|
+
}
|
|
141
|
+
|
|
214
142
|
async checkHealth() {
|
|
215
143
|
throw new StorageUnsupportedOperationError(`checkHealth() not implemented by ${this.constructor.name}`);
|
|
216
144
|
}
|
|
217
145
|
}
|
|
146
|
+
|