mbkbucket 4.1.0 → 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/lib/cli.js +1 -1
- package/lib/src/app.js +10 -24
- package/lib/src/controllers/api.controller.js +2 -1
- package/lib/src/controllers/dashboard.controller.js +2 -2
- package/lib/src/controllers/info.controller.js +2 -1
- package/lib/src/controllers/view.controller.js +2 -1
- package/lib/src/core/metrics.js +134 -134
- package/lib/src/core/storage/capabilities.js +58 -58
- package/lib/src/core/storage/errors.js +57 -57
- package/lib/src/core/storage/gdrive-auth.js +157 -157
- package/lib/src/core/storage/gdrive-provider.js +715 -714
- package/lib/src/core/storage/index.js +17 -17
- package/lib/src/core/storage/local-provider.js +507 -506
- package/lib/src/core/storage/models.js +121 -120
- package/lib/src/core/storage/path-strategy.js +85 -78
- package/lib/src/core/storage/s3-provider.js +472 -471
- package/lib/src/core/storage/storage-manager.js +226 -223
- package/lib/src/core/storage/storage-provider.js +146 -146
- package/lib/src/middleware/error-handler.js +15 -52
- package/lib/src/middleware/rate-limiter.js +1 -1
- package/lib/src/services/storage.service.js +1 -1
- package/lib/src/utils/cleanup-scheduler.js +91 -91
- package/lib/src/utils/helpers.js +2 -45
- package/package.json +3 -2
|
@@ -1,507 +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,
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
outStream.on('
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
let
|
|
279
|
-
let
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (typeof options.
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (isNaN(
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
stream,
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
const
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const { resolvedPath:
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const { resolvedPath:
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
await fs.promises.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
let
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
+
|
|
507
508
|
}
|