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.
Files changed (35) hide show
  1. package/.env.example +3 -2
  2. package/README.md +96 -44
  3. package/index.js +4 -1
  4. package/lib/cli.js +1 -1
  5. package/lib/public/bucketadmin.js +255 -33
  6. package/lib/public/mbkbucket-helper-demo.html +0 -58
  7. package/lib/public/mbkbucket-helper.js +1 -9
  8. package/lib/src/app.js +10 -24
  9. package/lib/src/config/validation.js +6 -1
  10. package/lib/src/controllers/api.controller.js +34 -6
  11. package/lib/src/controllers/dashboard.controller.js +2 -2
  12. package/lib/src/controllers/info.controller.js +15 -14
  13. package/lib/src/controllers/view.controller.js +24 -22
  14. package/lib/src/core/metrics.js +134 -0
  15. package/lib/src/core/storage/capabilities.js +58 -0
  16. package/lib/src/core/storage/errors.js +0 -5
  17. package/lib/src/core/storage/gdrive-auth.js +0 -36
  18. package/lib/src/core/storage/gdrive-provider.js +103 -44
  19. package/lib/src/core/storage/index.js +4 -1
  20. package/lib/src/core/storage/local-provider.js +508 -0
  21. package/lib/src/core/storage/models.js +91 -50
  22. package/lib/src/core/storage/path-strategy.js +22 -2
  23. package/lib/src/core/storage/s3-provider.js +19 -38
  24. package/lib/src/core/storage/storage-manager.js +76 -53
  25. package/lib/src/core/storage/storage-provider.js +49 -120
  26. package/lib/src/middleware/error-handler.js +15 -52
  27. package/lib/src/middleware/rate-limiter.js +1 -1
  28. package/lib/src/routes/api.routes.js +4 -1
  29. package/lib/src/services/s3.service.js +2 -2
  30. package/lib/src/services/storage.service.js +118 -20
  31. package/lib/src/utils/cleanup-scheduler.js +91 -0
  32. package/lib/src/utils/helpers.js +8 -51
  33. package/package.json +4 -4
  34. package/views/bucket.hbs +0 -5
  35. package/views/bucketadmin_preview_modal.hbs +3 -3
@@ -0,0 +1,508 @@
1
+ /**
2
+ * MBKBucket - Local Filesystem Storage Provider
3
+ * Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
4
+ * Licensed under the MIT License.
5
+ */
6
+
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import { Readable } from "stream";
10
+ import { StorageProvider } from "./storage-provider.js";
11
+ import { StorageCapabilities } from "./capabilities.js";
12
+ import { StorageValidationError, StorageNotFoundError, StorageError } from "./errors.js";
13
+ import { StorageListResult } from "./models.js";
14
+ import { nowIso, formatBytes } from "#helpers";
15
+ import { getFileExt, trimLeadingSlashes } from "mbkutils";
16
+ import { createLogger } from "#logger";
17
+
18
+
19
+ const debugLocal = createLogger('local-provider');
20
+
21
+ function sanitizeKey(key = '') {
22
+ return trimLeadingSlashes(String(key || '')).replace(/\\/g, '/');
23
+ }
24
+
25
+ function getMimeType(key = '') {
26
+ const ext = getFileExt(key);
27
+ const mimeMap = {
28
+ txt: 'text/plain',
29
+ html: 'text/html',
30
+ css: 'text/css',
31
+ js: 'application/javascript',
32
+ json: 'application/json',
33
+ png: 'image/png',
34
+ jpg: 'image/jpeg',
35
+ jpeg: 'image/jpeg',
36
+ gif: 'image/gif',
37
+ svg: 'image/svg+xml',
38
+ webp: 'image/webp',
39
+ pdf: 'application/pdf',
40
+ mp4: 'video/mp4',
41
+ mp3: 'audio/mpeg',
42
+ zip: 'application/zip'
43
+ };
44
+ return mimeMap[ext] || 'application/octet-stream';
45
+ }
46
+
47
+ export class LocalStorageProvider extends StorageProvider {
48
+
49
+ constructor(name, config = {}) {
50
+ super(name, config);
51
+ const rawPath = config.basePath || config.rootPath || config.directory || './mbkbucket_storage';
52
+ this.baseDirectory = path.resolve(process.cwd(), rawPath);
53
+
54
+ // Ensure base directory exists on construction
55
+ try {
56
+ if (!fs.existsSync(this.baseDirectory)) {
57
+ fs.mkdirSync(this.baseDirectory, { recursive: true });
58
+ }
59
+ } catch (err) {
60
+ debugLocal('Could not initialize base directory %s: %s', this.baseDirectory, err.message);
61
+ }
62
+ }
63
+
64
+ get type() {
65
+ return 'local';
66
+ }
67
+
68
+ get capabilities() {
69
+ return new StorageCapabilities({
70
+ multipart: false,
71
+ resumable: false,
72
+ nativeFolders: true,
73
+ ranges: true,
74
+ copy: true,
75
+ move: true,
76
+ search: true,
77
+ directDownloadUrl: false,
78
+ versioning: false,
79
+ publicUrls: false,
80
+ storageQuota: true,
81
+ deleteBatch: true,
82
+ metadata: true
83
+ });
84
+ }
85
+
86
+ resolvePath(key) {
87
+ const raw = String(key || '').trim();
88
+ const cleanKey = sanitizeKey(raw);
89
+
90
+ // Guard against Windows drive letters (C:) and root escapes
91
+ if (/^[a-zA-Z]:/.test(cleanKey) || /^[a-zA-Z]:/.test(raw) || raw.startsWith('/') || raw.startsWith('\\')) {
92
+ if (raw !== cleanKey && !cleanKey) {
93
+ throw new StorageValidationError(`Invalid storage key: path traversal or root escape detected for "${key}"`);
94
+ }
95
+ }
96
+
97
+ const resolvedPath = path.resolve(this.baseDirectory, cleanKey);
98
+
99
+ // Verify resolved path stays strictly inside base directory across OSes
100
+ const relative = path.relative(this.baseDirectory, resolvedPath);
101
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
102
+ throw new StorageValidationError(`Invalid storage key: path traversal detected for "${key}"`);
103
+ }
104
+
105
+ return { resolvedPath, relativeKey: cleanKey.replace(/\\/g, '/') };
106
+ }
107
+
108
+
109
+ async listFiles(prefix = '', options = {}) {
110
+ const cleanPrefix = sanitizeKey(prefix || '');
111
+ const { resolvedPath: dirPath } = this.resolvePath(cleanPrefix);
112
+ const isRecursive = options.recursive === true || options.recursive === 'true';
113
+ const delimiter = options.delimiter || (isRecursive ? '' : '/');
114
+ const search = (options.search || '').trim().toLowerCase();
115
+ const maxKeys = Math.max(1, Math.min(Number(options.maxKeys) || 1000, 5000));
116
+ const token = options.continuationToken || options.token || null;
117
+
118
+ if (!fs.existsSync(dirPath)) {
119
+ return new StorageListResult({
120
+ contents: [],
121
+ commonPrefixes: [],
122
+ nextToken: null,
123
+ isTruncated: false,
124
+ totalFiles: 0
125
+ });
126
+ }
127
+
128
+ const files = [];
129
+ const folders = [];
130
+ const seenFolders = new Set();
131
+
132
+ const scanDirectory = (currentDir, currentPrefix) => {
133
+ let entries = [];
134
+ try {
135
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
136
+ } catch (err) {
137
+ debugLocal('Failed reading directory %s: %s', currentDir, err.message);
138
+ return;
139
+ }
140
+
141
+ for (const entry of entries) {
142
+ const fullPath = path.join(currentDir, entry.name);
143
+ const relativeKey = path.relative(this.baseDirectory, fullPath).replace(/\\/g, '/');
144
+
145
+ if (entry.isDirectory()) {
146
+ const folderPrefix = relativeKey.endsWith('/') ? relativeKey : `${relativeKey}/`;
147
+
148
+ if (delimiter === '/') {
149
+ if (!seenFolders.has(folderPrefix)) {
150
+ seenFolders.add(folderPrefix);
151
+ folders.push({
152
+ name: entry.name,
153
+ prefix: folderPrefix,
154
+ path: folderPrefix
155
+ });
156
+ }
157
+ } else {
158
+ if (!seenFolders.has(folderPrefix)) {
159
+ seenFolders.add(folderPrefix);
160
+ folders.push({
161
+ name: entry.name,
162
+ prefix: folderPrefix,
163
+ path: folderPrefix
164
+ });
165
+ }
166
+ if (isRecursive) {
167
+ scanDirectory(fullPath, folderPrefix);
168
+ }
169
+ }
170
+ } else if (entry.isFile()) {
171
+ try {
172
+ const stat = fs.statSync(fullPath);
173
+ const fileName = entry.name;
174
+ const matchesSearch = !search || fileName.toLowerCase().includes(search) || relativeKey.toLowerCase().includes(search);
175
+
176
+ if (matchesSearch) {
177
+ files.push({
178
+ Key: relativeKey,
179
+ Size: stat.size,
180
+ LastModified: stat.mtime.toISOString(),
181
+ ETag: `"${stat.mtimeMs.toString(16)}-${stat.size.toString(16)}"`,
182
+ ContentType: getMimeType(fileName),
183
+ name: fileName,
184
+ sizeFormatted: formatBytes(stat.size)
185
+ });
186
+ }
187
+ } catch (err) {
188
+ debugLocal('Error reading file stat %s: %s', fullPath, err.message);
189
+ }
190
+ }
191
+ }
192
+ };
193
+
194
+ scanDirectory(dirPath, cleanPrefix);
195
+
196
+ // Sort alphabetically
197
+ files.sort((a, b) => a.Key.localeCompare(b.Key));
198
+ folders.sort((a, b) => a.prefix.localeCompare(b.prefix));
199
+
200
+ // Simple token-based pagination
201
+ let startIndex = 0;
202
+ if (token) {
203
+ const idx = files.findIndex(f => f.Key === token);
204
+ if (idx !== -1) startIndex = idx + 1;
205
+ }
206
+
207
+ const paginatedFiles = files.slice(startIndex, startIndex + maxKeys);
208
+ const isTruncated = (startIndex + maxKeys) < files.length;
209
+ const nextContinuationToken = isTruncated && paginatedFiles.length > 0
210
+ ? paginatedFiles[paginatedFiles.length - 1].Key
211
+ : null;
212
+
213
+ return new StorageListResult({
214
+ contents: paginatedFiles,
215
+ commonPrefixes: folders.map(f => ({ Prefix: f.prefix || f })),
216
+ nextToken: nextContinuationToken,
217
+ isTruncated,
218
+ totalFiles: files.length
219
+ });
220
+ }
221
+
222
+
223
+ async uploadFile(key, fileBuffer, contentType, options = {}) {
224
+ if (!key) throw new StorageValidationError("Storage key is required for upload");
225
+ const { resolvedPath, relativeKey } = this.resolvePath(key);
226
+ const dir = path.dirname(resolvedPath);
227
+
228
+ try {
229
+ if (!fs.existsSync(dir)) {
230
+ await fs.promises.mkdir(dir, { recursive: true });
231
+ }
232
+
233
+ if (fileBuffer instanceof Readable) {
234
+ const outStream = fs.createWriteStream(resolvedPath);
235
+ await new Promise((resolve, reject) => {
236
+ fileBuffer.pipe(outStream);
237
+ outStream.on('finish', resolve);
238
+ outStream.on('error', reject);
239
+ fileBuffer.on('error', reject);
240
+ });
241
+ } else {
242
+ const buffer = Buffer.isBuffer(fileBuffer) ? fileBuffer : Buffer.from(fileBuffer);
243
+ await fs.promises.writeFile(resolvedPath, buffer);
244
+ }
245
+
246
+ const stat = await fs.promises.stat(resolvedPath);
247
+ const mime = contentType || getMimeType(key);
248
+ const etag = `"${stat.mtimeMs.toString(16)}-${stat.size.toString(16)}"`;
249
+
250
+ return {
251
+ key: relativeKey,
252
+ Key: relativeKey,
253
+ size: stat.size,
254
+ Size: stat.size,
255
+ etag,
256
+ ETag: etag,
257
+ contentType: mime,
258
+ ContentType: mime,
259
+ uploadedAt: nowIso(),
260
+ storageType: 'local'
261
+ };
262
+ } catch (err) {
263
+ debugLocal('Upload error for key %s: %s', key, err.message);
264
+ throw new StorageError(`Local upload failed: ${err.message}`);
265
+ }
266
+ }
267
+
268
+ async downloadFile(key, options = {}) {
269
+ if (!key) throw new StorageValidationError("Storage key is required for download");
270
+ const { resolvedPath, relativeKey } = this.resolvePath(key);
271
+
272
+ try {
273
+ const stat = await fs.promises.stat(resolvedPath);
274
+ if (stat.isDirectory()) {
275
+ throw new StorageValidationError(`Key "${key}" refers to a directory, not a file`);
276
+ }
277
+
278
+ let start = 0;
279
+ let end = stat.size - 1;
280
+ let isRange = false;
281
+
282
+ if (options.range) {
283
+ const match = /^bytes=(\d*)-(\d*)$/.exec(String(options.range).trim());
284
+ if (match) {
285
+ const rawStart = match[1];
286
+ const rawEnd = match[2];
287
+ if (rawStart !== '') start = parseInt(rawStart, 10);
288
+ if (rawEnd !== '') end = parseInt(rawEnd, 10);
289
+ isRange = true;
290
+ }
291
+ } else if (typeof options.start === 'number' || typeof options.end === 'number') {
292
+ if (typeof options.start === 'number') start = options.start;
293
+ if (typeof options.end === 'number') end = options.end;
294
+ isRange = true;
295
+ }
296
+
297
+ if (isRange) {
298
+ if (isNaN(start) || start < 0) start = 0;
299
+ if (isNaN(end) || end >= stat.size) end = stat.size - 1;
300
+ if (start > end) start = end;
301
+ }
302
+
303
+ const stream = fs.createReadStream(resolvedPath, isRange ? { start, end } : undefined);
304
+ const contentType = getMimeType(key);
305
+ const contentLength = isRange ? (end - start + 1) : stat.size;
306
+ const etag = `"${stat.mtimeMs.toString(16)}-${stat.size.toString(16)}"`;
307
+
308
+ return {
309
+ Body: stream,
310
+ stream,
311
+ ContentType: contentType,
312
+ ContentLength: contentLength,
313
+ LastModified: stat.mtime,
314
+ ETag: etag,
315
+ isRange,
316
+ contentRange: isRange ? `bytes ${start}-${end}/${stat.size}` : null
317
+ };
318
+ } catch (err) {
319
+ if (err.code === 'ENOENT') {
320
+ throw new StorageNotFoundError(`File not found: ${key}`);
321
+ }
322
+ throw new StorageError(`Local download failed: ${err.message}`);
323
+ }
324
+
325
+ }
326
+
327
+ async deleteFile(key) {
328
+ if (!key) throw new StorageValidationError("Storage key is required for delete");
329
+ const { resolvedPath, relativeKey } = this.resolvePath(key);
330
+
331
+ try {
332
+ await fs.promises.unlink(resolvedPath);
333
+ return { key: relativeKey, deleted: true, deletedAt: nowIso() };
334
+ } catch (err) {
335
+ if (err.code === 'ENOENT') {
336
+ return { key: relativeKey, deleted: false, reason: 'notFound' };
337
+ }
338
+ throw new StorageError(`Local delete failed: ${err.message}`);
339
+ }
340
+ }
341
+
342
+ async deleteFolder(prefix) {
343
+ if (!prefix) throw new StorageValidationError("Prefix is required for deleteFolder");
344
+ const { resolvedPath, relativeKey } = this.resolvePath(prefix);
345
+
346
+ try {
347
+ if (fs.existsSync(resolvedPath)) {
348
+ await fs.promises.rm(resolvedPath, { recursive: true, force: true });
349
+ }
350
+ return { prefix: relativeKey, deleted: true, deletedAt: nowIso() };
351
+ } catch (err) {
352
+ throw new StorageError(`Local deleteFolder failed: ${err.message}`);
353
+ }
354
+ }
355
+
356
+ async createFolder(prefix) {
357
+ if (!prefix) throw new StorageValidationError("Prefix is required for createFolder");
358
+ const { resolvedPath, relativeKey } = this.resolvePath(prefix);
359
+
360
+ try {
361
+ await fs.promises.mkdir(resolvedPath, { recursive: true });
362
+ return { prefix: relativeKey, created: true, createdAt: nowIso() };
363
+ } catch (err) {
364
+ throw new StorageError(`Local createFolder failed: ${err.message}`);
365
+ }
366
+ }
367
+
368
+ async getFileMetadata(key) {
369
+ if (!key) throw new StorageValidationError("Storage key is required for metadata");
370
+ const { resolvedPath, relativeKey } = this.resolvePath(key);
371
+
372
+ try {
373
+ const stat = await fs.promises.stat(resolvedPath);
374
+ const isDir = stat.isDirectory();
375
+ const contentType = isDir ? 'application/x-directory' : getMimeType(key);
376
+ const etag = `"${stat.mtimeMs.toString(16)}-${stat.size.toString(16)}"`;
377
+
378
+ return {
379
+ exists: true,
380
+ Key: relativeKey,
381
+ ContentLength: isDir ? 0 : stat.size,
382
+ LastModified: stat.mtime,
383
+ ContentType: contentType,
384
+ ETag: etag,
385
+ isDirectory: isDir,
386
+ Metadata: {}
387
+ };
388
+ } catch (err) {
389
+ if (err.code === 'ENOENT') {
390
+ return { exists: false, Key: relativeKey };
391
+ }
392
+ throw new StorageError(`Local getFileMetadata failed: ${err.message}`);
393
+ }
394
+ }
395
+
396
+ async copyFile(sourceKey, destKey) {
397
+ if (!sourceKey || !destKey) throw new StorageValidationError("sourceKey and destKey are required for copy");
398
+ const { resolvedPath: srcPath, relativeKey: srcRel } = this.resolvePath(sourceKey);
399
+ const { resolvedPath: dstPath, relativeKey: dstRel } = this.resolvePath(destKey);
400
+
401
+ try {
402
+ const dstDir = path.dirname(dstPath);
403
+ if (!fs.existsSync(dstDir)) {
404
+ await fs.promises.mkdir(dstDir, { recursive: true });
405
+ }
406
+
407
+ await fs.promises.copyFile(srcPath, dstPath);
408
+ return { sourceKey: srcRel, destKey: dstRel, copiedAt: nowIso() };
409
+ } catch (err) {
410
+ if (err.code === 'ENOENT') {
411
+ throw new StorageNotFoundError(`Source file not found: ${sourceKey}`);
412
+ }
413
+ throw new StorageError(`Local copy failed: ${err.message}`);
414
+ }
415
+ }
416
+
417
+ async moveFile(sourceKey, destKey) {
418
+ if (!sourceKey || !destKey) throw new StorageValidationError("sourceKey and destKey are required for move");
419
+ const { resolvedPath: srcPath, relativeKey: srcRel } = this.resolvePath(sourceKey);
420
+ const { resolvedPath: dstPath, relativeKey: dstRel } = this.resolvePath(destKey);
421
+
422
+ try {
423
+ const dstDir = path.dirname(dstPath);
424
+ if (!fs.existsSync(dstDir)) {
425
+ await fs.promises.mkdir(dstDir, { recursive: true });
426
+ }
427
+
428
+ try {
429
+ await fs.promises.rename(srcPath, dstPath);
430
+ } catch (renameErr) {
431
+ // Fallback for cross-device moves
432
+ await fs.promises.copyFile(srcPath, dstPath);
433
+ await fs.promises.unlink(srcPath);
434
+ }
435
+
436
+ return { sourceKey: srcRel, destKey: dstRel, movedAt: nowIso() };
437
+ } catch (err) {
438
+ if (err.code === 'ENOENT') {
439
+ throw new StorageNotFoundError(`Source file not found: ${sourceKey}`);
440
+ }
441
+ throw new StorageError(`Local move failed: ${err.message}`);
442
+ }
443
+ }
444
+
445
+ async getStorageQuota() {
446
+ let totalBytes = 0;
447
+ let fileCount = 0;
448
+
449
+ const calculateSize = (dir) => {
450
+ try {
451
+ if (!fs.existsSync(dir)) return;
452
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
453
+ for (const entry of entries) {
454
+ const fullPath = path.join(dir, entry.name);
455
+ if (entry.isDirectory()) {
456
+ calculateSize(fullPath);
457
+ } else if (entry.isFile()) {
458
+ const stat = fs.statSync(fullPath);
459
+ totalBytes += stat.size;
460
+ fileCount++;
461
+ }
462
+ }
463
+ } catch {}
464
+ };
465
+
466
+ calculateSize(this.baseDirectory);
467
+
468
+ return {
469
+ usedBytes: totalBytes,
470
+ fileCount,
471
+ usedFormatted: formatBytes(totalBytes),
472
+ path: this.baseDirectory
473
+ };
474
+ }
475
+
476
+ async checkHealth() {
477
+ const startTime = Date.now();
478
+ const rand = Math.random().toString(36).slice(2, 8);
479
+ const testFile = path.join(this.baseDirectory, `.health_${process.pid}_${Date.now()}_${rand}.tmp`);
480
+ try {
481
+ if (!fs.existsSync(this.baseDirectory)) {
482
+ await fs.promises.mkdir(this.baseDirectory, { recursive: true });
483
+ }
484
+ // Test read/write permission with a temporary marker file
485
+ await fs.promises.writeFile(testFile, 'ok');
486
+ try { await fs.promises.unlink(testFile); } catch {}
487
+
488
+ return {
489
+ status: 'healthy',
490
+ type: 'local',
491
+ baseDirectory: this.baseDirectory,
492
+ responseTime: Date.now() - startTime,
493
+ checkedAt: nowIso()
494
+ };
495
+ } catch (err) {
496
+ try { if (fs.existsSync(testFile)) await fs.promises.unlink(testFile); } catch {}
497
+ return {
498
+ status: 'unhealthy',
499
+ type: 'local',
500
+ baseDirectory: this.baseDirectory,
501
+ error: err.message,
502
+ responseTime: Date.now() - startTime,
503
+ checkedAt: nowIso()
504
+ };
505
+ }
506
+ }
507
+
508
+ }
@@ -4,68 +4,105 @@
4
4
  * Licensed under the MIT License.
5
5
  */
6
6
 
7
- import { nowIso } from "#helpers";
7
+ import { nowIso, getFolderPath } from "#helpers";
8
+ import { getBaseName, trimLeadingSlashes, trimSlashes } from "mbkutils";
9
+
10
+ export class StorageItem {
11
+ constructor(props = {}) {
12
+ const rawPath = String(props.path || props.key || props.Key || props.prefix || props.Prefix || '');
13
+ this.path = props.type === 'folder'
14
+ ? (rawPath ? (rawPath.endsWith('/') ? rawPath : `${rawPath}/`) : '')
15
+ : trimLeadingSlashes(rawPath);
16
+
17
+ this.type = props.type || (this.path.endsWith('/') ? 'folder' : 'file');
18
+ this.name = props.name || (this.type === 'folder' ? trimSlashes(getBaseName(this.path.replace(/\/+$/, ''))) : getBaseName(this.path));
19
+ this.parentPath = props.parentPath ?? (this.type === 'folder'
20
+ ? trimSlashes(getFolderPath(this.path.replace(/\/+$/, '')))
21
+ : trimSlashes(getFolderPath(this.path)));
22
+
23
+ this.id = props.id || props.nativeId || this.path;
24
+ this.nativeId = props.nativeId || props.id || null;
25
+ this.provider = props.provider || props.storageClass || props.StorageClass || 'generic';
26
+
27
+ this.size = this.type === 'folder' ? 0 : Number(props.size ?? props.Size ?? 0);
28
+ this.mimeType = props.mimeType || props.contentType || props.ContentType || (this.type === 'folder' ? 'application/x-directory' : 'application/octet-stream');
29
+ this.etag = props.etag || props.ETag || '';
30
+
31
+ const modDate = props.lastModified || props.LastModified || props.updatedAt;
32
+ this.lastModified = modDate instanceof Date ? modDate : new Date(modDate || Date.now());
33
+
34
+ if (props.createdAt || props.createdTime) {
35
+ const createDate = props.createdAt || props.createdTime;
36
+ this.createdAt = createDate instanceof Date ? createDate : new Date(createDate);
37
+ }
38
+
39
+ this.metadata = Object.freeze({ ...(props.metadata || {}) });
40
+ }
41
+
42
+ get isFolder() {
43
+ return this.type === 'folder';
44
+ }
45
+
46
+ get isFile() {
47
+ return this.type === 'file';
48
+ }
49
+ }
50
+
51
+ export class StorageFile extends StorageItem {
52
+ constructor(props = {}) {
53
+ const key = props.key || props.Key || props.path || '';
54
+ super({
55
+ ...props,
56
+ type: 'file',
57
+ path: key,
58
+ size: props.size ?? props.Size ?? 0,
59
+ mimeType: props.mimeType || props.contentType || props.ContentType,
60
+ lastModified: props.lastModified || props.LastModified,
61
+ etag: props.etag || props.ETag,
62
+ provider: props.storageClass || props.StorageClass || props.provider || 'STANDARD',
63
+ id: props.id,
64
+ nativeId: props.id || props.nativeId
65
+ });
66
+
67
+ // Backward-compatible properties for S3 consumers
68
+ this.Key = this.path;
69
+ this.Size = this.size;
70
+ this.LastModified = this.lastModified;
71
+ this.ETag = this.etag;
72
+ this.ContentType = this.mimeType;
73
+ this.StorageClass = this.provider;
8
74
 
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
75
  if (props.id) this.id = props.id;
36
76
  if (props.mimeType) this.mimeType = props.mimeType;
37
77
  if (props.webViewLink) this.webViewLink = props.webViewLink;
38
78
  if (props.webContentLink) this.webContentLink = props.webContentLink;
39
- if (props.metadata) this.metadata = props.metadata;
40
79
  }
41
80
  }
42
81
 
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;
82
+ export class StorageFolder extends StorageItem {
83
+ constructor(prefixOrProps, id) {
84
+ const props = typeof prefixOrProps === 'object' && prefixOrProps !== null
85
+ ? prefixOrProps
86
+ : { prefix: prefixOrProps, id };
87
+
88
+ const rawPrefix = String(props.prefix || props.Prefix || props.path || '');
89
+ const cleanPrefix = rawPrefix ? (rawPrefix.endsWith('/') ? rawPrefix : `${rawPrefix}/`) : '';
90
+
91
+ super({
92
+ ...props,
93
+ type: 'folder',
94
+ path: cleanPrefix,
95
+ id: props.id || id,
96
+ nativeId: props.id || id
97
+ });
98
+
99
+ // Backward-compatible property for S3 consumers
100
+ this.Prefix = this.path;
101
+ if (this.id) this.id = this.id;
54
102
  }
55
103
  }
56
104
 
57
- /**
58
- * Standardized paginated listing result.
59
- */
60
105
  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
106
  constructor(props = {}) {
70
107
  this.Contents = props.contents || props.Contents || [];
71
108
  this.CommonPrefixes = props.commonPrefixes || props.CommonPrefixes || [];
@@ -76,5 +113,9 @@ export class StorageListResult {
76
113
  this.KeyCount = this.Contents.length;
77
114
  this.totalFiles = props.totalFiles ?? this.Contents.length;
78
115
  this.requestedAt = nowIso();
116
+
117
+ this.files = this.Contents;
118
+ this.folders = this.CommonPrefixes;
119
+ this.items = [...this.CommonPrefixes, ...this.Contents];
79
120
  }
80
121
  }