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.
Files changed (38) hide show
  1. package/.env.example +15 -14
  2. package/README.md +561 -377
  3. package/index.js +32 -30
  4. package/lib/cli.js +154 -565
  5. package/lib/public/bucketadmin.css +2042 -1708
  6. package/lib/public/bucketadmin.js +1622 -1286
  7. package/lib/public/mbkbucket-helper-demo.html +4 -24
  8. package/lib/public/mbkbucket-helper.css +28 -34
  9. package/lib/public/mbkbucket-helper.js +4 -3
  10. package/lib/src/config/validation.js +86 -73
  11. package/lib/src/controllers/api.controller.js +459 -410
  12. package/lib/src/controllers/dashboard.controller.js +69 -49
  13. package/lib/src/controllers/info.controller.js +34 -3
  14. package/lib/src/controllers/view.controller.js +233 -231
  15. package/lib/src/core/storage/errors.js +62 -0
  16. package/lib/src/core/storage/gdrive-auth.js +193 -0
  17. package/lib/src/core/storage/gdrive-provider.js +656 -0
  18. package/lib/src/core/storage/index.js +14 -0
  19. package/lib/src/core/storage/models.js +80 -0
  20. package/lib/src/core/storage/path-strategy.js +65 -0
  21. package/lib/src/core/storage/s3-provider.js +491 -0
  22. package/lib/src/core/storage/storage-manager.js +203 -0
  23. package/lib/src/core/storage/storage-provider.js +217 -0
  24. package/lib/src/middleware/bucket-resolver.js +37 -37
  25. package/lib/src/routes/api.routes.js +5 -1
  26. package/lib/src/routes/index.js +60 -60
  27. package/lib/src/routes/view.routes.js +28 -25
  28. package/lib/src/services/s3.service.js +42 -594
  29. package/lib/src/services/storage.service.js +213 -0
  30. package/lib/src/utils/errors.js +56 -46
  31. package/package.json +11 -12
  32. package/views/bucket.hbs +47 -71
  33. package/views/bucketadmin_move_modal.hbs +35 -0
  34. package/views/bucketadmin_preview_modal.hbs +108 -40
  35. package/views/bucketadmincontent.hbs +193 -144
  36. package/views/mbkbucket_info.hbs +134 -232
  37. package/.mbkbucket.config.example.json +0 -14
  38. 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
+ }