mbkbucket 3.8.2 → 4.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.
- package/.env.example +15 -14
- package/README.md +561 -377
- package/index.js +32 -30
- package/lib/cli.js +154 -565
- package/lib/public/bucketadmin.css +2042 -1708
- package/lib/public/bucketadmin.js +1622 -1286
- package/lib/public/mbkbucket-helper-demo.html +4 -24
- package/lib/public/mbkbucket-helper.css +28 -34
- package/lib/public/mbkbucket-helper.js +4 -3
- package/lib/src/config/validation.js +86 -73
- package/lib/src/controllers/api.controller.js +459 -410
- package/lib/src/controllers/dashboard.controller.js +69 -49
- package/lib/src/controllers/info.controller.js +34 -3
- package/lib/src/controllers/view.controller.js +233 -231
- package/lib/src/core/storage/errors.js +62 -0
- package/lib/src/core/storage/gdrive-auth.js +193 -0
- package/lib/src/core/storage/gdrive-provider.js +656 -0
- package/lib/src/core/storage/index.js +14 -0
- package/lib/src/core/storage/models.js +80 -0
- package/lib/src/core/storage/path-strategy.js +65 -0
- package/lib/src/core/storage/s3-provider.js +491 -0
- package/lib/src/core/storage/storage-manager.js +203 -0
- package/lib/src/core/storage/storage-provider.js +217 -0
- package/lib/src/middleware/bucket-resolver.js +37 -37
- package/lib/src/routes/api.routes.js +5 -1
- package/lib/src/routes/index.js +60 -60
- package/lib/src/routes/view.routes.js +28 -25
- package/lib/src/services/s3.service.js +42 -594
- package/lib/src/services/storage.service.js +213 -0
- package/lib/src/utils/errors.js +56 -46
- package/package.json +11 -12
- package/views/bucket.hbs +47 -71
- package/views/bucketadmin_move_modal.hbs +35 -0
- package/views/bucketadmin_preview_modal.hbs +108 -40
- package/views/bucketadmincontent.hbs +193 -144
- package/views/mbkbucket_info.hbs +134 -232
- package/.mbkbucket.config.example.json +0 -14
- package/lib/src/cli/auth-helper.js +0 -141
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MBKBucket - Normalized Storage Data Models
|
|
3
|
+
* Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
|
|
4
|
+
* Licensed under the MIT License.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { nowIso } from "#helpers";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Standardized file item representation across all providers.
|
|
11
|
+
*/
|
|
12
|
+
export class StorageFile {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} props
|
|
15
|
+
* @param {string} props.key - Normalized virtual key / path
|
|
16
|
+
* @param {number} [props.size=0] - File size in bytes
|
|
17
|
+
* @param {Date|string} [props.lastModified] - Last modified timestamp
|
|
18
|
+
* @param {string} [props.etag] - Entity tag / checksum
|
|
19
|
+
* @param {string} [props.contentType] - MIME content type
|
|
20
|
+
* @param {string} [props.storageClass] - Storage tier / provider type
|
|
21
|
+
* @param {string} [props.id] - Native provider ID (e.g. Google Drive file ID)
|
|
22
|
+
* @param {string} [props.webViewLink] - Web preview link
|
|
23
|
+
* @param {string} [props.webContentLink] - Direct download link
|
|
24
|
+
* @param {object} [props.metadata={}] - Custom user metadata
|
|
25
|
+
*/
|
|
26
|
+
constructor(props) {
|
|
27
|
+
this.Key = props.key || props.Key || '';
|
|
28
|
+
this.Size = Number(props.size ?? props.Size ?? 0);
|
|
29
|
+
this.LastModified = props.lastModified instanceof Date
|
|
30
|
+
? props.lastModified
|
|
31
|
+
: new Date(props.lastModified || props.LastModified || Date.now());
|
|
32
|
+
this.ETag = props.etag || props.ETag || '';
|
|
33
|
+
this.ContentType = props.contentType || props.ContentType || 'application/octet-stream';
|
|
34
|
+
this.StorageClass = props.storageClass || props.StorageClass || 'STANDARD';
|
|
35
|
+
if (props.id) this.id = props.id;
|
|
36
|
+
if (props.mimeType) this.mimeType = props.mimeType;
|
|
37
|
+
if (props.webViewLink) this.webViewLink = props.webViewLink;
|
|
38
|
+
if (props.webContentLink) this.webContentLink = props.webContentLink;
|
|
39
|
+
if (props.metadata) this.metadata = props.metadata;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Standardized folder representation.
|
|
45
|
+
*/
|
|
46
|
+
export class StorageFolder {
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} prefix - Folder prefix with trailing slash
|
|
49
|
+
* @param {string} [id] - Native provider folder ID
|
|
50
|
+
*/
|
|
51
|
+
constructor(prefix, id) {
|
|
52
|
+
this.Prefix = String(prefix || '').endsWith('/') ? String(prefix) : `${prefix}/`;
|
|
53
|
+
if (id) this.id = id;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Standardized paginated listing result.
|
|
59
|
+
*/
|
|
60
|
+
export class StorageListResult {
|
|
61
|
+
/**
|
|
62
|
+
* @param {object} props
|
|
63
|
+
* @param {StorageFile[]} [props.contents=[]]
|
|
64
|
+
* @param {StorageFolder[]} [props.commonPrefixes=[]]
|
|
65
|
+
* @param {string|null} [props.nextToken=null]
|
|
66
|
+
* @param {boolean} [props.isTruncated=false]
|
|
67
|
+
* @param {number} [props.totalFiles]
|
|
68
|
+
*/
|
|
69
|
+
constructor(props = {}) {
|
|
70
|
+
this.Contents = props.contents || props.Contents || [];
|
|
71
|
+
this.CommonPrefixes = props.commonPrefixes || props.CommonPrefixes || [];
|
|
72
|
+
this.NextContinuationToken = props.nextToken ?? props.NextContinuationToken ?? null;
|
|
73
|
+
this.nextToken = this.NextContinuationToken;
|
|
74
|
+
this.IsTruncated = Boolean(props.isTruncated ?? props.IsTruncated ?? Boolean(this.NextContinuationToken));
|
|
75
|
+
this.hasMore = this.IsTruncated;
|
|
76
|
+
this.KeyCount = this.Contents.length;
|
|
77
|
+
this.totalFiles = props.totalFiles ?? this.Contents.length;
|
|
78
|
+
this.requestedAt = nowIso();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MBKBucket - Path & Key Strategy
|
|
3
|
+
* Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
|
|
4
|
+
* Licensed under the MIT License.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mbkautheVar } from "mbkauthe";
|
|
8
|
+
import { StorageValidationError } from "./errors.js";
|
|
9
|
+
import { trimLeadingSlashes, trimSlashes } from "#helpers";
|
|
10
|
+
|
|
11
|
+
const escapeRegExp = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
12
|
+
|
|
13
|
+
export class PathStrategy {
|
|
14
|
+
static getAppName() {
|
|
15
|
+
const app = mbkautheVar?.APP_NAME;
|
|
16
|
+
return typeof app === 'string' && app.trim() ? app.trim() : '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static isRootModeApp(appName) {
|
|
20
|
+
return ['portal', 'mbkbucket'].includes(String(appName || '').toLowerCase());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static validateSafety(str, isKey = false) {
|
|
24
|
+
if (isKey && !str) throw new StorageValidationError('Key is required');
|
|
25
|
+
if (str && (str.includes('../') || str.includes('..\\') || /\.\.[\\/]/.test(str))) {
|
|
26
|
+
throw new StorageValidationError(`Path traversal detected: invalid ${isKey ? 'key' : 'prefix'}`);
|
|
27
|
+
}
|
|
28
|
+
if (str && /[\x00-\x1f\x7f]/.test(str)) {
|
|
29
|
+
throw new StorageValidationError(`Invalid characters in ${isKey ? 'key' : 'prefix'}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static applyKeyPrefix(key = '') {
|
|
34
|
+
if (Array.isArray(key)) key = key.join('/');
|
|
35
|
+
this.validateSafety(key, true);
|
|
36
|
+
|
|
37
|
+
const app = this.getAppName();
|
|
38
|
+
const cleaned = trimLeadingSlashes(key);
|
|
39
|
+
if (this.isRootModeApp(app)) return cleaned;
|
|
40
|
+
if (!app) throw new StorageValidationError('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
|
|
41
|
+
|
|
42
|
+
const appPrefixRegex = new RegExp(`^(?:${escapeRegExp(app)}\/)+`);
|
|
43
|
+
return cleaned.startsWith(`${app}/`) ? cleaned.replace(appPrefixRegex, `${app}/`) : `${app}/${cleaned}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static applyPrefix(prefix = '') {
|
|
47
|
+
if (Array.isArray(prefix)) prefix = prefix.join('/');
|
|
48
|
+
this.validateSafety(prefix, false);
|
|
49
|
+
|
|
50
|
+
const app = this.getAppName();
|
|
51
|
+
const p = trimLeadingSlashes(prefix);
|
|
52
|
+
if (this.isRootModeApp(app)) return p;
|
|
53
|
+
if (!app) throw new StorageValidationError('APP_NAME is not configured; set mbkautheVar.APP_NAME or MBKAUTHE_APP_NAME');
|
|
54
|
+
if (!p || p === app || p === `${app}/`) return p || app;
|
|
55
|
+
|
|
56
|
+
return p.startsWith(`${app}/`)
|
|
57
|
+
? p.replace(new RegExp(`^(?:${escapeRegExp(app)}\/)+`), `${app}/`)
|
|
58
|
+
: `${app}/${p}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static normalizeFolderPrefix(prefix = '') {
|
|
62
|
+
const p = this.applyPrefix(trimSlashes(prefix));
|
|
63
|
+
return p ? `${p}/` : '';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MBKBucket - S3 / Cloudflare R2 / MinIO / iDrive E2 Storage Provider
|
|
3
|
+
* Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
|
|
4
|
+
* Licensed under the MIT License.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { S3Client, ListObjectsV2Command, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, HeadObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, ListMultipartUploadsCommand, CopyObjectCommand } from '@aws-sdk/client-s3';
|
|
8
|
+
import { StorageProvider } from './storage-provider.js';
|
|
9
|
+
import { StorageFile, StorageFolder, StorageListResult } from './models.js';
|
|
10
|
+
import { StorageNotFoundError, StorageConflictError, StorageAccessDeniedError, StorageValidationError, StorageError } from './errors.js';
|
|
11
|
+
import { createLogger } from "#logger";
|
|
12
|
+
import { nowIso, trimSlashes } from "#helpers";
|
|
13
|
+
|
|
14
|
+
const debugS3 = createLogger('s3-provider');
|
|
15
|
+
|
|
16
|
+
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
|
+
constructor(name, config = {}) {
|
|
22
|
+
super(name, config);
|
|
23
|
+
this.bucket = config.BUCKET_NAME;
|
|
24
|
+
this.region = config.region || 'auto';
|
|
25
|
+
this.client = new S3Client({
|
|
26
|
+
region: this.region,
|
|
27
|
+
endpoint: config.ENDPOINT,
|
|
28
|
+
credentials: {
|
|
29
|
+
accessKeyId: config.ACCESS_KEY_ID,
|
|
30
|
+
secretAccessKey: config.SECRET_ACCESS_KEY,
|
|
31
|
+
},
|
|
32
|
+
maxAttempts: 3,
|
|
33
|
+
retryMode: 'adaptive',
|
|
34
|
+
requestTimeout: 120000,
|
|
35
|
+
requestHandler: {
|
|
36
|
+
connectionTimeout: 5000,
|
|
37
|
+
socketTimeout: 120000,
|
|
38
|
+
maxSockets: 50,
|
|
39
|
+
keepAlive: true,
|
|
40
|
+
keepAliveMsecs: 1000,
|
|
41
|
+
},
|
|
42
|
+
useAccelerateEndpoint: false,
|
|
43
|
+
forcePathStyle: true,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get type() {
|
|
48
|
+
return 's3';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get capabilities() {
|
|
52
|
+
return {
|
|
53
|
+
multipart: true,
|
|
54
|
+
presignedUrls: true,
|
|
55
|
+
nativeFolders: false,
|
|
56
|
+
ranges: true,
|
|
57
|
+
copy: true,
|
|
58
|
+
move: true,
|
|
59
|
+
search: false,
|
|
60
|
+
directDownloadUrl: false
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async checkHealth() {
|
|
65
|
+
const startTime = Date.now();
|
|
66
|
+
try {
|
|
67
|
+
await this.client.send(new ListObjectsV2Command({
|
|
68
|
+
Bucket: this.bucket,
|
|
69
|
+
MaxKeys: 1
|
|
70
|
+
}));
|
|
71
|
+
return {
|
|
72
|
+
status: 'healthy',
|
|
73
|
+
providerType: 's3',
|
|
74
|
+
responseTime: Date.now() - startTime,
|
|
75
|
+
bucket: this.bucket,
|
|
76
|
+
region: this.region,
|
|
77
|
+
checkedAt: nowIso()
|
|
78
|
+
};
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return {
|
|
81
|
+
status: 'unhealthy',
|
|
82
|
+
providerType: 's3',
|
|
83
|
+
error: error.message,
|
|
84
|
+
bucket: this.bucket || 'unknown',
|
|
85
|
+
checkedAt: nowIso()
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async uploadFile(key, fileBuffer, contentType, options = {}) {
|
|
91
|
+
try {
|
|
92
|
+
if (!key || !fileBuffer) throw new StorageValidationError('Key and file buffer are required');
|
|
93
|
+
|
|
94
|
+
const {
|
|
95
|
+
metadata = {},
|
|
96
|
+
cacheControl = 'public, max-age=31536000',
|
|
97
|
+
storageClass = 'STANDARD',
|
|
98
|
+
serverSideEncryption = 'AES256',
|
|
99
|
+
preventOverwrite = false
|
|
100
|
+
} = options;
|
|
101
|
+
|
|
102
|
+
debugS3('Uploading file to bucket=%s key=%s', this.bucket, key);
|
|
103
|
+
const uploadedAt = nowIso();
|
|
104
|
+
|
|
105
|
+
const commandParams = {
|
|
106
|
+
Bucket: this.bucket,
|
|
107
|
+
Key: key,
|
|
108
|
+
Body: fileBuffer,
|
|
109
|
+
ContentType: contentType,
|
|
110
|
+
CacheControl: cacheControl,
|
|
111
|
+
Metadata: {
|
|
112
|
+
'uploaded-at': uploadedAt,
|
|
113
|
+
'file-size': fileBuffer.length.toString(),
|
|
114
|
+
'upload-source': 'web-portal',
|
|
115
|
+
...metadata
|
|
116
|
+
},
|
|
117
|
+
ServerSideEncryption: serverSideEncryption,
|
|
118
|
+
StorageClass: storageClass,
|
|
119
|
+
...(preventOverwrite && { IfNoneMatch: '*' })
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const result = await this.client.send(new PutObjectCommand(commandParams));
|
|
123
|
+
return {
|
|
124
|
+
...result,
|
|
125
|
+
fileSize: fileBuffer.length,
|
|
126
|
+
key,
|
|
127
|
+
contentType,
|
|
128
|
+
uploadedAt
|
|
129
|
+
};
|
|
130
|
+
} catch (error) {
|
|
131
|
+
debugS3('Upload failed for key %s: %s', key, error.message);
|
|
132
|
+
if (error.name === 'PreconditionFailed' || error.$metadata?.httpStatusCode === 412) {
|
|
133
|
+
throw new StorageConflictError('File already exists');
|
|
134
|
+
}
|
|
135
|
+
if (error instanceof StorageError) throw error;
|
|
136
|
+
throw new StorageError(`Upload failed: ${error.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async downloadFile(key, options = {}) {
|
|
141
|
+
try {
|
|
142
|
+
if (!key) throw new StorageValidationError('Key is required');
|
|
143
|
+
|
|
144
|
+
const { range, ifNoneMatch, ifModifiedSince, responseCacheControl, responseContentType } = options;
|
|
145
|
+
if (range) debugS3('Range request for %s: %s', key, range);
|
|
146
|
+
|
|
147
|
+
const result = await this.client.send(new GetObjectCommand({
|
|
148
|
+
Bucket: this.bucket,
|
|
149
|
+
Key: key,
|
|
150
|
+
...(range && { Range: range }),
|
|
151
|
+
...(ifNoneMatch && { IfNoneMatch: ifNoneMatch }),
|
|
152
|
+
...(ifModifiedSince && { IfModifiedSince: ifModifiedSince }),
|
|
153
|
+
...(responseCacheControl && { ResponseCacheControl: responseCacheControl }),
|
|
154
|
+
...(responseContentType && { ResponseContentType: responseContentType }),
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
return { ...result, key };
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (error?.$metadata?.httpStatusCode === 304 || error?.name === '304' || error?.name === 'NotModified') {
|
|
160
|
+
debugS3('File not modified (304) for key %s', key);
|
|
161
|
+
return { notModified: true, key };
|
|
162
|
+
}
|
|
163
|
+
debugS3('Download failed for key %s: %s', key, error.message);
|
|
164
|
+
if (error.name === 'NoSuchKey' || error.name === 'NotFound') throw new StorageNotFoundError(`File not found: ${key}`);
|
|
165
|
+
if (error.name === 'AccessDenied') throw new StorageAccessDeniedError(`Access denied for file: ${key}`);
|
|
166
|
+
if (error instanceof StorageError) throw error;
|
|
167
|
+
throw new StorageError(`Download failed: ${error.message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async deleteFile(key, _options = {}) {
|
|
172
|
+
try {
|
|
173
|
+
if (!key) throw new StorageValidationError('Key is required');
|
|
174
|
+
|
|
175
|
+
const result = await this.client.send(new DeleteObjectCommand({
|
|
176
|
+
Bucket: this.bucket,
|
|
177
|
+
Key: key,
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
return { ...result, key, deletedAt: nowIso() };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
debugS3('Delete failed for %s: %s', key, error.message);
|
|
183
|
+
if (error instanceof StorageError) throw error;
|
|
184
|
+
throw new StorageError(`Delete failed: ${error.message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async deleteFiles(keys, _options = {}) {
|
|
189
|
+
try {
|
|
190
|
+
if (!keys || !Array.isArray(keys) || !keys.length) {
|
|
191
|
+
throw new StorageValidationError('Keys array is required and must not be empty');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const maxBatchSize = 1000;
|
|
195
|
+
const results = [];
|
|
196
|
+
let deletedCount = 0;
|
|
197
|
+
const errors = [];
|
|
198
|
+
|
|
199
|
+
for (let i = 0; i < keys.length; i += maxBatchSize) {
|
|
200
|
+
const batch = keys.slice(i, i + maxBatchSize).map(k => ({ Key: k }));
|
|
201
|
+
const result = await this.client.send(new DeleteObjectsCommand({
|
|
202
|
+
Bucket: this.bucket,
|
|
203
|
+
Delete: { Objects: batch, Quiet: false }
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
results.push(result);
|
|
207
|
+
deletedCount += result.Deleted?.length || 0;
|
|
208
|
+
if (result.Errors?.length) errors.push(...result.Errors);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { results, deletedCount, errors, deletedAt: nowIso() };
|
|
212
|
+
} catch (error) {
|
|
213
|
+
debugS3('Batch delete failed: %s', error.message);
|
|
214
|
+
if (error instanceof StorageError) throw error;
|
|
215
|
+
throw new StorageError(`Batch delete failed: ${error.message}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async deleteFolder(prefix, _options = {}) {
|
|
220
|
+
try {
|
|
221
|
+
if (!prefix) throw new StorageValidationError('Prefix is required');
|
|
222
|
+
const cleanPrefix = trimSlashes(prefix);
|
|
223
|
+
const effectivePrefix = cleanPrefix ? `${cleanPrefix}/` : '';
|
|
224
|
+
|
|
225
|
+
const keysToDelete = [];
|
|
226
|
+
let continuationToken = null;
|
|
227
|
+
|
|
228
|
+
do {
|
|
229
|
+
const resp = await this.client.send(new ListObjectsV2Command({
|
|
230
|
+
Bucket: this.bucket,
|
|
231
|
+
Prefix: effectivePrefix,
|
|
232
|
+
ContinuationToken: continuationToken,
|
|
233
|
+
MaxKeys: 1000
|
|
234
|
+
}));
|
|
235
|
+
(resp.Contents || []).forEach(obj => { if (obj.Key) keysToDelete.push(obj.Key); });
|
|
236
|
+
continuationToken = resp.IsTruncated ? resp.NextContinuationToken : null;
|
|
237
|
+
} while (continuationToken);
|
|
238
|
+
|
|
239
|
+
if (!keysToDelete.length) {
|
|
240
|
+
const exists = await this.fileExists(effectivePrefix);
|
|
241
|
+
if (exists) {
|
|
242
|
+
await this.deleteFile(effectivePrefix);
|
|
243
|
+
return { deletedCount: 1, deletedAt: nowIso(), prefix: effectivePrefix };
|
|
244
|
+
}
|
|
245
|
+
return { deletedCount: 0, deletedAt: nowIso(), prefix: effectivePrefix };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const result = await this.deleteFiles(keysToDelete);
|
|
249
|
+
return { ...result, prefix: effectivePrefix, deletedAt: nowIso() };
|
|
250
|
+
} catch (error) {
|
|
251
|
+
debugS3('Delete folder failed for %s: %s', prefix, error.message);
|
|
252
|
+
if (error instanceof StorageError) throw error;
|
|
253
|
+
throw new StorageError(`Delete folder failed: ${error.message}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async listFiles(prefix = '', options = {}) {
|
|
258
|
+
try {
|
|
259
|
+
const {
|
|
260
|
+
maxKeys = 1000,
|
|
261
|
+
continuationToken = null,
|
|
262
|
+
delimiter = null,
|
|
263
|
+
fetchOwner = false,
|
|
264
|
+
startAfter = null
|
|
265
|
+
} = options;
|
|
266
|
+
|
|
267
|
+
const result = await this.client.send(new ListObjectsV2Command({
|
|
268
|
+
Bucket: this.bucket,
|
|
269
|
+
Prefix: prefix || '',
|
|
270
|
+
MaxKeys: maxKeys,
|
|
271
|
+
...(continuationToken && { ContinuationToken: continuationToken }),
|
|
272
|
+
...(delimiter && { Delimiter: delimiter }),
|
|
273
|
+
...(fetchOwner && { FetchOwner: fetchOwner }),
|
|
274
|
+
...(startAfter && { StartAfter: startAfter }),
|
|
275
|
+
}));
|
|
276
|
+
|
|
277
|
+
const contents = (result.Contents || []).map(item => new StorageFile({
|
|
278
|
+
key: item.Key,
|
|
279
|
+
size: item.Size,
|
|
280
|
+
lastModified: item.LastModified,
|
|
281
|
+
etag: item.ETag,
|
|
282
|
+
storageClass: item.StorageClass || 'STANDARD'
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
const commonPrefixes = (result.CommonPrefixes || []).map(p => new StorageFolder(p.Prefix));
|
|
286
|
+
|
|
287
|
+
return new StorageListResult({
|
|
288
|
+
contents,
|
|
289
|
+
commonPrefixes,
|
|
290
|
+
nextToken: result.NextContinuationToken || null,
|
|
291
|
+
isTruncated: Boolean(result.IsTruncated),
|
|
292
|
+
totalFiles: result.KeyCount || contents.length
|
|
293
|
+
});
|
|
294
|
+
} catch (error) {
|
|
295
|
+
debugS3('List files failed for prefix %s: %s', prefix, error.message);
|
|
296
|
+
if (error instanceof StorageError) throw error;
|
|
297
|
+
throw new StorageError(`List files failed: ${error.message}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async getFileMetadata(key, _options = {}) {
|
|
302
|
+
try {
|
|
303
|
+
if (!key) throw new StorageValidationError('Key is required');
|
|
304
|
+
|
|
305
|
+
const result = await this.client.send(new HeadObjectCommand({
|
|
306
|
+
Bucket: this.bucket,
|
|
307
|
+
Key: key,
|
|
308
|
+
}));
|
|
309
|
+
|
|
310
|
+
return { ...result, key, exists: true, queriedAt: nowIso() };
|
|
311
|
+
} catch (error) {
|
|
312
|
+
const queriedAt = nowIso();
|
|
313
|
+
if (error.name === 'NotFound' || error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
|
|
314
|
+
return { key, exists: false, queriedAt };
|
|
315
|
+
}
|
|
316
|
+
debugS3('Get metadata failed for key %s: %s', key, error.message);
|
|
317
|
+
if (error instanceof StorageError) throw error;
|
|
318
|
+
throw new StorageError(`Get metadata failed: ${error.message}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async createFolder(key, _options = {}) {
|
|
323
|
+
let folderKey = key;
|
|
324
|
+
if (!folderKey.endsWith('/')) folderKey += '/';
|
|
325
|
+
|
|
326
|
+
if (await this.fileExists(folderKey)) {
|
|
327
|
+
return { key: folderKey, success: true, message: 'Folder already exists' };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const existingFiles = await this.listFiles(folderKey, { maxKeys: 1 });
|
|
331
|
+
if (existingFiles.Contents?.length) {
|
|
332
|
+
return { key: folderKey, success: true, message: 'Folder already exists with content', skipMarker: true };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await this.uploadFile(folderKey, Buffer.alloc(0), 'application/x-empty', {
|
|
336
|
+
metadata: { folder: 'true', marker: 'true' }
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
return { key: folderKey, success: true, message: 'Folder created' };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async copyFile(sourceKey, destKey, options = {}) {
|
|
343
|
+
try {
|
|
344
|
+
const copySource = encodeURIComponent(`${this.bucket}/${sourceKey}`);
|
|
345
|
+
await this.client.send(new CopyObjectCommand({
|
|
346
|
+
Bucket: this.bucket,
|
|
347
|
+
Key: destKey,
|
|
348
|
+
CopySource: copySource,
|
|
349
|
+
MetadataDirective: options.metadata ? 'REPLACE' : 'COPY',
|
|
350
|
+
...(options.metadata && { Metadata: options.metadata })
|
|
351
|
+
}));
|
|
352
|
+
return { sourceKey, destKey, copiedAt: nowIso() };
|
|
353
|
+
} catch (error) {
|
|
354
|
+
debugS3('Copy failed from %s to %s: %s', sourceKey, destKey, error.message);
|
|
355
|
+
if (error instanceof StorageError) throw error;
|
|
356
|
+
throw new StorageError(`Copy failed: ${error.message}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// S3 Multipart Upload Operations
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
async createMultipartUpload(key, contentType = 'application/octet-stream', metadata = {}) {
|
|
393
|
+
const result = await this.client.send(new CreateMultipartUploadCommand({
|
|
394
|
+
Bucket: this.bucket,
|
|
395
|
+
Key: key,
|
|
396
|
+
ContentType: contentType,
|
|
397
|
+
Metadata: {
|
|
398
|
+
'upload-source': 'web-portal',
|
|
399
|
+
'uploaded-at': nowIso(),
|
|
400
|
+
...metadata
|
|
401
|
+
}
|
|
402
|
+
}));
|
|
403
|
+
return { uploadId: result.UploadId, key };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async uploadPart(key, uploadId, partNumber, buffer) {
|
|
407
|
+
const result = await this.client.send(new UploadPartCommand({
|
|
408
|
+
Bucket: this.bucket,
|
|
409
|
+
Key: key,
|
|
410
|
+
UploadId: uploadId,
|
|
411
|
+
PartNumber: partNumber,
|
|
412
|
+
Body: buffer,
|
|
413
|
+
ContentLength: buffer.length
|
|
414
|
+
}));
|
|
415
|
+
return { ETag: result.ETag, partNumber };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async completeMultipartUpload(key, uploadId, parts) {
|
|
419
|
+
const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber);
|
|
420
|
+
await this.client.send(new CompleteMultipartUploadCommand({
|
|
421
|
+
Bucket: this.bucket,
|
|
422
|
+
Key: key,
|
|
423
|
+
UploadId: uploadId,
|
|
424
|
+
MultipartUpload: {
|
|
425
|
+
Parts: sorted.map(p => ({ PartNumber: p.partNumber, ETag: p.ETag }))
|
|
426
|
+
}
|
|
427
|
+
}));
|
|
428
|
+
return { key };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async abortMultipartUpload(key, uploadId) {
|
|
432
|
+
await this.client.send(new AbortMultipartUploadCommand({
|
|
433
|
+
Bucket: this.bucket,
|
|
434
|
+
Key: key,
|
|
435
|
+
UploadId: uploadId
|
|
436
|
+
}));
|
|
437
|
+
return { key, abortedAt: nowIso() };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async listIncompleteMultipartUploads(prefix = '') {
|
|
441
|
+
try {
|
|
442
|
+
const result = await this.client.send(new ListMultipartUploadsCommand({
|
|
443
|
+
Bucket: this.bucket,
|
|
444
|
+
Prefix: prefix || ''
|
|
445
|
+
}));
|
|
446
|
+
return result.Uploads || [];
|
|
447
|
+
} catch (error) {
|
|
448
|
+
debugS3('Failed to list incomplete multipart uploads: %s', error.message);
|
|
449
|
+
if (error instanceof StorageError) throw error;
|
|
450
|
+
throw new StorageError(`Failed to list incomplete uploads: ${error.message}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async cleanupIncompleteMultipartUploads(olderThanDays = 7, prefix = '') {
|
|
455
|
+
try {
|
|
456
|
+
const uploads = await this.listIncompleteMultipartUploads(prefix);
|
|
457
|
+
if (!uploads.length) {
|
|
458
|
+
debugS3('No incomplete multipart uploads found');
|
|
459
|
+
return { abortedCount: 0, uploads: [] };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const cutoffDate = new Date();
|
|
463
|
+
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
|
464
|
+
|
|
465
|
+
const uploadsToAbort = uploads.filter(u => u.Initiated && new Date(u.Initiated) < cutoffDate);
|
|
466
|
+
if (!uploadsToAbort.length) {
|
|
467
|
+
debugS3('No incomplete uploads older than %s days', olderThanDays);
|
|
468
|
+
return { abortedCount: 0, uploads: [] };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
debugS3('Found %s incomplete uploads to clean up', uploadsToAbort.length);
|
|
472
|
+
const aborted = [];
|
|
473
|
+
|
|
474
|
+
for (const upload of uploadsToAbort) {
|
|
475
|
+
try {
|
|
476
|
+
await this.abortMultipartUpload(upload.Key, upload.UploadId);
|
|
477
|
+
aborted.push({ key: upload.Key, uploadId: upload.UploadId, initiated: upload.Initiated });
|
|
478
|
+
debugS3('Aborted incomplete upload: %s (initiated: %s)', upload.Key, upload.Initiated);
|
|
479
|
+
} catch (err) {
|
|
480
|
+
debugS3('Failed to abort upload %s: %s', upload.Key, err.message);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return { abortedCount: aborted.length, uploads: aborted, cleanedAt: nowIso() };
|
|
485
|
+
} catch (error) {
|
|
486
|
+
debugS3('Cleanup of incomplete multipart uploads failed: %s', error.message);
|
|
487
|
+
if (error instanceof StorageError) throw error;
|
|
488
|
+
throw new StorageError(`Cleanup failed: ${error.message}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|