express-storage 2.0.3 → 3.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 (125) hide show
  1. package/README.md +366 -34
  2. package/dist/cjs/config/index.d.ts +10 -0
  3. package/dist/cjs/config/index.d.ts.map +1 -0
  4. package/dist/cjs/config/index.js +19 -0
  5. package/dist/cjs/config/index.js.map +1 -0
  6. package/dist/cjs/drivers/azure.driver.d.ts +27 -42
  7. package/dist/cjs/drivers/azure.driver.d.ts.map +1 -1
  8. package/dist/cjs/drivers/azure.driver.js +206 -212
  9. package/dist/cjs/drivers/azure.driver.js.map +1 -1
  10. package/dist/cjs/drivers/base.driver.d.ts +69 -103
  11. package/dist/cjs/drivers/base.driver.d.ts.map +1 -1
  12. package/dist/cjs/drivers/base.driver.js +170 -167
  13. package/dist/cjs/drivers/base.driver.js.map +1 -1
  14. package/dist/cjs/drivers/gcs.driver.d.ts +20 -38
  15. package/dist/cjs/drivers/gcs.driver.d.ts.map +1 -1
  16. package/dist/cjs/drivers/gcs.driver.js +160 -176
  17. package/dist/cjs/drivers/gcs.driver.js.map +1 -1
  18. package/dist/cjs/drivers/index.d.ts +15 -0
  19. package/dist/cjs/drivers/index.d.ts.map +1 -0
  20. package/dist/cjs/drivers/index.js +26 -0
  21. package/dist/cjs/drivers/index.js.map +1 -0
  22. package/dist/cjs/drivers/local.driver.d.ts +24 -45
  23. package/dist/cjs/drivers/local.driver.d.ts.map +1 -1
  24. package/dist/cjs/drivers/local.driver.js +266 -338
  25. package/dist/cjs/drivers/local.driver.js.map +1 -1
  26. package/dist/cjs/drivers/s3.driver.d.ts +19 -39
  27. package/dist/cjs/drivers/s3.driver.d.ts.map +1 -1
  28. package/dist/cjs/drivers/s3.driver.js +205 -197
  29. package/dist/cjs/drivers/s3.driver.js.map +1 -1
  30. package/dist/cjs/factory/driver.factory.d.ts +32 -51
  31. package/dist/cjs/factory/driver.factory.d.ts.map +1 -1
  32. package/dist/cjs/factory/driver.factory.js +75 -155
  33. package/dist/cjs/factory/driver.factory.js.map +1 -1
  34. package/dist/cjs/index.d.ts +11 -15
  35. package/dist/cjs/index.d.ts.map +1 -1
  36. package/dist/cjs/index.js +14 -47
  37. package/dist/cjs/index.js.map +1 -1
  38. package/dist/cjs/storage-manager.d.ts +107 -125
  39. package/dist/cjs/storage-manager.d.ts.map +1 -1
  40. package/dist/cjs/storage-manager.js +346 -416
  41. package/dist/cjs/storage-manager.js.map +1 -1
  42. package/dist/cjs/types/storage.types.d.ts +250 -107
  43. package/dist/cjs/types/storage.types.d.ts.map +1 -1
  44. package/dist/cjs/utils/file.utils.d.ts +62 -8
  45. package/dist/cjs/utils/file.utils.d.ts.map +1 -1
  46. package/dist/cjs/utils/file.utils.js +196 -29
  47. package/dist/cjs/utils/file.utils.js.map +1 -1
  48. package/dist/cjs/utils/index.d.ts +12 -0
  49. package/dist/cjs/utils/index.d.ts.map +1 -0
  50. package/dist/cjs/utils/index.js +36 -0
  51. package/dist/cjs/utils/index.js.map +1 -0
  52. package/dist/cjs/utils/rate-limiter.d.ts +40 -0
  53. package/dist/cjs/utils/rate-limiter.d.ts.map +1 -0
  54. package/dist/cjs/utils/rate-limiter.js +87 -0
  55. package/dist/cjs/utils/rate-limiter.js.map +1 -0
  56. package/dist/esm/config/index.d.ts +10 -0
  57. package/dist/esm/config/index.d.ts.map +1 -0
  58. package/dist/esm/config/index.js +10 -0
  59. package/dist/esm/config/index.js.map +1 -0
  60. package/dist/esm/drivers/azure.driver.d.ts +27 -42
  61. package/dist/esm/drivers/azure.driver.d.ts.map +1 -1
  62. package/dist/esm/drivers/azure.driver.js +172 -210
  63. package/dist/esm/drivers/azure.driver.js.map +1 -1
  64. package/dist/esm/drivers/base.driver.d.ts +69 -103
  65. package/dist/esm/drivers/base.driver.d.ts.map +1 -1
  66. package/dist/esm/drivers/base.driver.js +171 -168
  67. package/dist/esm/drivers/base.driver.js.map +1 -1
  68. package/dist/esm/drivers/gcs.driver.d.ts +20 -38
  69. package/dist/esm/drivers/gcs.driver.d.ts.map +1 -1
  70. package/dist/esm/drivers/gcs.driver.js +126 -174
  71. package/dist/esm/drivers/gcs.driver.js.map +1 -1
  72. package/dist/esm/drivers/index.d.ts +15 -0
  73. package/dist/esm/drivers/index.d.ts.map +1 -0
  74. package/dist/esm/drivers/index.js +15 -0
  75. package/dist/esm/drivers/index.js.map +1 -0
  76. package/dist/esm/drivers/local.driver.d.ts +24 -45
  77. package/dist/esm/drivers/local.driver.d.ts.map +1 -1
  78. package/dist/esm/drivers/local.driver.js +266 -338
  79. package/dist/esm/drivers/local.driver.js.map +1 -1
  80. package/dist/esm/drivers/s3.driver.d.ts +19 -39
  81. package/dist/esm/drivers/s3.driver.d.ts.map +1 -1
  82. package/dist/esm/drivers/s3.driver.js +171 -195
  83. package/dist/esm/drivers/s3.driver.js.map +1 -1
  84. package/dist/esm/factory/driver.factory.d.ts +32 -51
  85. package/dist/esm/factory/driver.factory.d.ts.map +1 -1
  86. package/dist/esm/factory/driver.factory.js +73 -158
  87. package/dist/esm/factory/driver.factory.js.map +1 -1
  88. package/dist/esm/index.d.ts +11 -15
  89. package/dist/esm/index.d.ts.map +1 -1
  90. package/dist/esm/index.js +12 -19
  91. package/dist/esm/index.js.map +1 -1
  92. package/dist/esm/storage-manager.d.ts +107 -125
  93. package/dist/esm/storage-manager.d.ts.map +1 -1
  94. package/dist/esm/storage-manager.js +348 -418
  95. package/dist/esm/storage-manager.js.map +1 -1
  96. package/dist/esm/types/storage.types.d.ts +250 -107
  97. package/dist/esm/types/storage.types.d.ts.map +1 -1
  98. package/dist/esm/utils/file.utils.d.ts +62 -8
  99. package/dist/esm/utils/file.utils.d.ts.map +1 -1
  100. package/dist/esm/utils/file.utils.js +190 -29
  101. package/dist/esm/utils/file.utils.js.map +1 -1
  102. package/dist/esm/utils/index.d.ts +12 -0
  103. package/dist/esm/utils/index.d.ts.map +1 -0
  104. package/dist/esm/utils/index.js +11 -0
  105. package/dist/esm/utils/index.js.map +1 -0
  106. package/dist/esm/utils/rate-limiter.d.ts +40 -0
  107. package/dist/esm/utils/rate-limiter.d.ts.map +1 -0
  108. package/dist/esm/utils/rate-limiter.js +82 -0
  109. package/dist/esm/utils/rate-limiter.js.map +1 -0
  110. package/package.json +83 -48
  111. package/src/config/index.ts +17 -0
  112. package/src/drivers/azure.driver.ts +434 -0
  113. package/src/drivers/base.driver.ts +436 -0
  114. package/src/drivers/gcs.driver.ts +366 -0
  115. package/src/drivers/index.ts +15 -0
  116. package/src/drivers/local.driver.ts +626 -0
  117. package/src/drivers/s3.driver.ts +459 -0
  118. package/src/factory/driver.factory.ts +101 -0
  119. package/src/index.ts +72 -0
  120. package/src/storage-manager.ts +801 -0
  121. package/src/types/storage.types.ts +561 -0
  122. package/src/utils/config.utils.ts +229 -0
  123. package/src/utils/file.utils.ts +536 -0
  124. package/src/utils/index.ts +35 -0
  125. package/src/utils/rate-limiter.ts +94 -0
@@ -0,0 +1,626 @@
1
+ import fs from 'fs';
2
+ import fsPromises from 'fs/promises';
3
+ import path from 'path';
4
+ import { BaseStorageDriver } from './base.driver.js';
5
+ import { FileUploadResult, PresignedUrlResult, StorageConfig, ListFilesResult, UploadOptions, FileInfo, BlobValidationOptions, BlobValidationResult, BlobValidationSuccess, DeleteResult } from '../types/storage.types.js';
6
+ import { createMonthBasedPath, ensureDirectoryExists } from '../utils/file.utils.js';
7
+
8
+ /**
9
+ * Magic byte signatures for common file types.
10
+ * Used to detect actual file content type regardless of extension.
11
+ * This provides security against extension spoofing attacks.
12
+ */
13
+ const MAGIC_BYTES: Array<{ bytes: number[]; mimeType: string; offset?: number }> = [
14
+ // Images
15
+ { bytes: [0xFF, 0xD8, 0xFF], mimeType: 'image/jpeg' },
16
+ { bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], mimeType: 'image/png' },
17
+ { bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], mimeType: 'image/gif' },
18
+ { bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], mimeType: 'image/gif' },
19
+ { bytes: [0x42, 0x4D], mimeType: 'image/bmp' },
20
+ // Documents
21
+ { bytes: [0x25, 0x50, 0x44, 0x46], mimeType: 'application/pdf' },
22
+ { bytes: [0x50, 0x4B, 0x03, 0x04], mimeType: 'application/zip' },
23
+ { bytes: [0x50, 0x4B, 0x05, 0x06], mimeType: 'application/zip' },
24
+ { bytes: [0x50, 0x4B, 0x07, 0x08], mimeType: 'application/zip' },
25
+ // Archives
26
+ { bytes: [0x1F, 0x8B], mimeType: 'application/gzip' },
27
+ { bytes: [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], mimeType: 'application/vnd.rar' },
28
+ { bytes: [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], mimeType: 'application/x-7z-compressed' },
29
+ // Audio/Video
30
+ { bytes: [0x49, 0x44, 0x33], mimeType: 'audio/mpeg' },
31
+ { bytes: [0xFF, 0xFB], mimeType: 'audio/mpeg' },
32
+ { bytes: [0xFF, 0xFA], mimeType: 'audio/mpeg' },
33
+ { bytes: [0x4F, 0x67, 0x67, 0x53], mimeType: 'audio/ogg' },
34
+ { bytes: [0x66, 0x74, 0x79, 0x70], mimeType: 'video/mp4', offset: 4 },
35
+ // Executables (for security detection)
36
+ { bytes: [0x4D, 0x5A], mimeType: 'application/x-msdownload' },
37
+ { bytes: [0x7F, 0x45, 0x4C, 0x46], mimeType: 'application/x-executable' },
38
+ ];
39
+
40
+ /**
41
+ * Detects MIME type from file content using magic bytes.
42
+ * Returns undefined if no match is found (falls back to extension-based detection).
43
+ */
44
+ async function detectMimeTypeFromMagicBytes(filePath: string): Promise<string | undefined> {
45
+ try {
46
+ const fd = await fsPromises.open(filePath, 'r');
47
+ const buffer = Buffer.alloc(16);
48
+ const { bytesRead } = await fd.read(buffer, 0, 16, 0);
49
+ await fd.close();
50
+
51
+ if (bytesRead === 0) {
52
+ return undefined;
53
+ }
54
+
55
+ for (const signature of MAGIC_BYTES) {
56
+ const offset = signature.offset || 0;
57
+ if (offset + signature.bytes.length > bytesRead) {
58
+ continue;
59
+ }
60
+
61
+ let matches = true;
62
+ for (let i = 0; i < signature.bytes.length; i++) {
63
+ if (buffer[offset + i] !== signature.bytes[i]) {
64
+ matches = false;
65
+ break;
66
+ }
67
+ }
68
+
69
+ if (matches) {
70
+ return signature.mimeType;
71
+ }
72
+ }
73
+
74
+ // RIFF container: bytes 0-3 = 'RIFF', bytes 8-11 = sub-format identifier
75
+ if (bytesRead >= 12 &&
76
+ buffer[0] === 0x52 && buffer[1] === 0x49 &&
77
+ buffer[2] === 0x46 && buffer[3] === 0x46) {
78
+ const subFormat = buffer.subarray(8, 12).toString('ascii');
79
+ switch (subFormat) {
80
+ case 'WEBP': return 'image/webp';
81
+ case 'WAVE': return 'audio/wav';
82
+ case 'AVI ': return 'video/x-msvideo';
83
+ }
84
+ }
85
+
86
+ return undefined;
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Detects content type using magic bytes first, then extension as fallback.
94
+ */
95
+ async function detectContentType(filePath: string, reference: string): Promise<string | undefined> {
96
+ const magicMime = await detectMimeTypeFromMagicBytes(filePath);
97
+ if (magicMime) return magicMime;
98
+ const ext = path.extname(reference).toLowerCase();
99
+ return ext && EXTENSION_MIME_MAP[ext] ? EXTENSION_MIME_MAP[ext] : undefined;
100
+ }
101
+
102
+ /**
103
+ * Maps file extensions to MIME types.
104
+ * Used as fallback when magic byte detection doesn't match.
105
+ */
106
+ const EXTENSION_MIME_MAP: Record<string, string> = {
107
+ // Images
108
+ '.jpg': 'image/jpeg',
109
+ '.jpeg': 'image/jpeg',
110
+ '.png': 'image/png',
111
+ '.gif': 'image/gif',
112
+ '.webp': 'image/webp',
113
+ '.svg': 'image/svg+xml',
114
+ '.ico': 'image/x-icon',
115
+ '.bmp': 'image/bmp',
116
+ '.tiff': 'image/tiff',
117
+ '.tif': 'image/tiff',
118
+ // Documents
119
+ '.pdf': 'application/pdf',
120
+ '.doc': 'application/msword',
121
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
122
+ '.xls': 'application/vnd.ms-excel',
123
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
124
+ '.ppt': 'application/vnd.ms-powerpoint',
125
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
126
+ '.txt': 'text/plain',
127
+ '.csv': 'text/csv',
128
+ '.json': 'application/json',
129
+ '.xml': 'application/xml',
130
+ // Audio
131
+ '.mp3': 'audio/mpeg',
132
+ '.wav': 'audio/wav',
133
+ '.ogg': 'audio/ogg',
134
+ '.m4a': 'audio/mp4',
135
+ // Video
136
+ '.mp4': 'video/mp4',
137
+ '.webm': 'video/webm',
138
+ '.avi': 'video/x-msvideo',
139
+ '.mov': 'video/quicktime',
140
+ '.mkv': 'video/x-matroska',
141
+ // Archives
142
+ '.zip': 'application/zip',
143
+ '.tar': 'application/x-tar',
144
+ '.gz': 'application/gzip',
145
+ '.rar': 'application/vnd.rar',
146
+ '.7z': 'application/x-7z-compressed',
147
+ // Web
148
+ '.html': 'text/html',
149
+ '.htm': 'text/html',
150
+ '.css': 'text/css',
151
+ '.js': 'application/javascript',
152
+ '.ts': 'application/typescript',
153
+ // Fonts
154
+ '.woff': 'font/woff',
155
+ '.woff2': 'font/woff2',
156
+ '.ttf': 'font/ttf',
157
+ '.otf': 'font/otf',
158
+ };
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // listFiles helpers — extracted for testability and clarity
162
+ // ---------------------------------------------------------------------------
163
+
164
+ const MAX_RECURSION_DEPTH = 100;
165
+ const MAX_ENTRIES_SCANNED = 50_000;
166
+
167
+ function couldContainPrefix(dirRelativePath: string, targetPrefix: string): boolean {
168
+ if (!targetPrefix) return true;
169
+ return targetPrefix.startsWith(dirRelativePath) ||
170
+ dirRelativePath.startsWith(targetPrefix) ||
171
+ dirRelativePath === '';
172
+ }
173
+
174
+ function isAfterToken(filePath: string, token: string | undefined): boolean {
175
+ if (!token) return true;
176
+ return filePath.localeCompare(token) > 0;
177
+ }
178
+
179
+ async function buildFileInfo(absolutePath: string, relativePath: string): Promise<FileInfo | null> {
180
+ try {
181
+ const stat = await fsPromises.stat(absolutePath);
182
+ const ext = path.extname(relativePath).toLowerCase();
183
+ return {
184
+ name: relativePath,
185
+ size: stat.size,
186
+ lastModified: stat.mtime,
187
+ contentType: (ext && EXTENSION_MIME_MAP[ext]) ? EXTENSION_MIME_MAP[ext] : 'application/octet-stream',
188
+ };
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ interface WalkOptions {
195
+ prefix?: string | undefined;
196
+ continuationToken?: string | undefined;
197
+ maxCollect: number;
198
+ }
199
+
200
+ interface WalkResult {
201
+ files: FileInfo[];
202
+ hasMore: boolean;
203
+ }
204
+
205
+ async function walkDirectory(baseDir: string, options: WalkOptions): Promise<WalkResult> {
206
+ const files: FileInfo[] = [];
207
+ let hasMore = false;
208
+ let entriesScanned = 0;
209
+
210
+ const walk = async (dir: string, dirRelativePath: string, depth: number): Promise<boolean> => {
211
+ if (depth > MAX_RECURSION_DEPTH || files.length >= options.maxCollect || entriesScanned >= MAX_ENTRIES_SCANNED) {
212
+ if (files.length >= options.maxCollect || entriesScanned >= MAX_ENTRIES_SCANNED) hasMore = true;
213
+ return files.length < options.maxCollect && entriesScanned < MAX_ENTRIES_SCANNED;
214
+ }
215
+
216
+ if (options.prefix && !couldContainPrefix(dirRelativePath, options.prefix)) {
217
+ return true;
218
+ }
219
+
220
+ let entries: fs.Dirent[];
221
+ try {
222
+ entries = await fsPromises.readdir(dir, { withFileTypes: true });
223
+ } catch {
224
+ return true;
225
+ }
226
+
227
+ entries.sort((a, b) => a.name.localeCompare(b.name));
228
+
229
+ for (const entry of entries) {
230
+ entriesScanned++;
231
+ if (files.length >= options.maxCollect || entriesScanned >= MAX_ENTRIES_SCANNED) {
232
+ hasMore = true;
233
+ return false;
234
+ }
235
+
236
+ if (entry.isSymbolicLink()) continue;
237
+
238
+ const itemPath = path.join(dir, entry.name);
239
+ const relativePath = dirRelativePath ? `${dirRelativePath}/${entry.name}` : entry.name;
240
+
241
+ if (entry.isDirectory()) {
242
+ if (options.continuationToken && !couldContainPrefix(relativePath, options.continuationToken.split('/')[0] || '')) {
243
+ if (relativePath.localeCompare(options.continuationToken) < 0 && !options.continuationToken.startsWith(relativePath + '/')) {
244
+ continue;
245
+ }
246
+ }
247
+ if (!await walk(itemPath, relativePath, depth + 1)) return false;
248
+ } else if (entry.isFile()) {
249
+ if (options.prefix && !relativePath.startsWith(options.prefix)) continue;
250
+ if (!isAfterToken(relativePath, options.continuationToken)) continue;
251
+
252
+ const info = await buildFileInfo(itemPath, relativePath);
253
+ if (info) files.push(info);
254
+ }
255
+ }
256
+ return true;
257
+ };
258
+
259
+ await walk(baseDir, '', 0);
260
+ return { files, hasMore };
261
+ }
262
+
263
+ function paginateFiles(files: FileInfo[], maxResults: number, hasMore: boolean): ListFilesResult {
264
+ files.sort((a, b) => a.name.localeCompare(b.name));
265
+ const page = files.slice(0, maxResults);
266
+
267
+ const result: ListFilesResult = { success: true, files: page };
268
+
269
+ if (files.length > maxResults || hasMore) {
270
+ const lastFile = page[page.length - 1];
271
+ if (lastFile) result.nextToken = lastFile.name;
272
+ }
273
+
274
+ return result;
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // LocalStorageDriver
279
+ // ---------------------------------------------------------------------------
280
+
281
+ /**
282
+ * LocalStorageDriver - Saves files to your local filesystem.
283
+ *
284
+ * Great for development and small-scale applications.
285
+ * Files are organized by year/month folders automatically.
286
+ *
287
+ * **Security features:**
288
+ * - Path traversal prevention (blocks ../ and null bytes)
289
+ * - Symlinks are NOT followed or deleted (prevents directory escape attacks)
290
+ * - Magic byte detection for content-type validation (prevents extension spoofing)
291
+ * - Files stay within the configured base directory
292
+ *
293
+ * Note: Local storage doesn't support presigned URLs since
294
+ * there's no external service to sign requests against.
295
+ */
296
+ export class LocalStorageDriver extends BaseStorageDriver {
297
+ private readonly basePath: string;
298
+ private readonly originalLocalPath: string;
299
+
300
+ constructor(config: StorageConfig) {
301
+ super(config);
302
+ this.originalLocalPath = config.localPath || 'public/express-storage';
303
+ this.basePath = path.resolve(this.originalLocalPath);
304
+ }
305
+
306
+ /**
307
+ * Saves a file to the local filesystem.
308
+ *
309
+ * Files are automatically organized into YYYY/MM folders.
310
+ * For large files (>100MB), uses streaming to reduce memory usage.
311
+ */
312
+ async upload(file: Express.Multer.File, options?: UploadOptions): Promise<FileUploadResult> {
313
+ try {
314
+ const { errors: validationErrors, resolvedSize } = await this.validateFile(file);
315
+ if (validationErrors.length > 0) {
316
+ return this.createErrorResult(validationErrors.join(', '), 'VALIDATION_FAILED');
317
+ }
318
+
319
+ const fileName = this.generateFileName(file.originalname);
320
+ const monthPath = createMonthBasedPath(this.basePath);
321
+ const fullDirPath = path.resolve(monthPath);
322
+
323
+ await ensureDirectoryExists(fullDirPath);
324
+
325
+ const filePath = path.join(fullDirPath, fileName);
326
+
327
+ options?.signal?.throwIfAborted();
328
+
329
+ if (this.shouldUseStreaming(resolvedSize)) {
330
+ await this.uploadWithStream(file, filePath);
331
+ } else {
332
+ const fileContent = await this.getFileContent(file);
333
+ await fsPromises.writeFile(filePath, fileContent);
334
+ }
335
+
336
+ if (options?.metadata && Object.keys(options.metadata).length > 0) {
337
+ const meta: Record<string, unknown> = { metadata: options.metadata };
338
+ if (options.contentType) meta['contentType'] = options.contentType;
339
+ if (options.cacheControl) meta['cacheControl'] = options.cacheControl;
340
+ if (options.contentDisposition) meta['contentDisposition'] = options.contentDisposition;
341
+ const metaPath = filePath + '.meta.json';
342
+ await fsPromises.writeFile(metaPath, JSON.stringify(meta));
343
+ }
344
+
345
+ const fileUrl = this.generateFileUrl(filePath);
346
+
347
+ const relativePath = this.normalizePathSeparators(
348
+ path.relative(this.basePath, path.resolve(filePath))
349
+ );
350
+
351
+ return this.createSuccessResult(relativePath, fileUrl);
352
+ } catch (error) {
353
+ await this.cleanupTempFile(file);
354
+ return this.createErrorResult(
355
+ error instanceof Error ? error.message : 'Failed to upload file'
356
+ );
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Uploads a large file using streaming.
362
+ * Pipes the file stream directly to disk for memory efficiency.
363
+ */
364
+ private async uploadWithStream(file: Express.Multer.File, filePath: string): Promise<void> {
365
+ return new Promise((resolve, reject) => {
366
+ const readStream = this.getFileStream(file);
367
+ const writeStream = fs.createWriteStream(filePath);
368
+
369
+ readStream
370
+ .pipe(writeStream)
371
+ .on('finish', resolve)
372
+ .on('error', (err) => {
373
+ void fsPromises.unlink(filePath).catch(() => {});
374
+ reject(err);
375
+ });
376
+
377
+ readStream.on('error', (err) => {
378
+ writeStream.destroy();
379
+ void fsPromises.unlink(filePath).catch(() => {});
380
+ reject(err);
381
+ });
382
+ });
383
+ }
384
+
385
+ /**
386
+ * Builds a URL for accessing the file.
387
+ *
388
+ * If basePath starts with 'public/', strips that prefix since
389
+ * Express.static('public') serves files from /
390
+ */
391
+ private generateFileUrl(filePath: string): string {
392
+ const absoluteFilePath = path.resolve(filePath);
393
+ const relativeFromBase = this.normalizePathSeparators(
394
+ path.relative(this.basePath, absoluteFilePath)
395
+ );
396
+
397
+ const normalizedLocalPath = this.normalizePathSeparators(this.originalLocalPath);
398
+
399
+ if (normalizedLocalPath.startsWith('public/')) {
400
+ const webBasePath = normalizedLocalPath.replace(/^public\//, '');
401
+ return this.normalizeUrl(`/${webBasePath}/${relativeFromBase}`);
402
+ }
403
+
404
+ return this.normalizeUrl(`/${normalizedLocalPath}/${relativeFromBase}`);
405
+ }
406
+
407
+ private normalizePathSeparators(pathStr: string): string {
408
+ return pathStr.replace(/\\/g, '/');
409
+ }
410
+
411
+ private normalizeUrl(url: string): string {
412
+ return url.replace(/\/+/g, '/');
413
+ }
414
+
415
+ /**
416
+ * Local storage doesn't support presigned upload URLs.
417
+ */
418
+ // eslint-disable-next-line @typescript-eslint/require-await
419
+ async generateUploadUrl(_fileName: string, _contentType?: string, _maxSize?: number): Promise<PresignedUrlResult> {
420
+ return this.createPresignedErrorResult(
421
+ 'Presigned URLs are not supported for local storage',
422
+ 'PRESIGNED_NOT_SUPPORTED'
423
+ );
424
+ }
425
+
426
+ /**
427
+ * Local storage doesn't support presigned view URLs.
428
+ */
429
+ // eslint-disable-next-line @typescript-eslint/require-await
430
+ async generateViewUrl(_fileName: string): Promise<PresignedUrlResult> {
431
+ return this.createPresignedErrorResult(
432
+ 'Presigned URLs are not supported for local storage',
433
+ 'PRESIGNED_NOT_SUPPORTED'
434
+ );
435
+ }
436
+
437
+ /**
438
+ * Validates a local file exists and matches expected values.
439
+ *
440
+ * Content type detection uses a two-tier approach:
441
+ * 1. Magic byte detection (examines actual file content for security)
442
+ * 2. Extension-based fallback (when magic bytes don't match)
443
+ */
444
+ override async validateAndConfirmUpload(
445
+ reference: string,
446
+ options?: BlobValidationOptions
447
+ ): Promise<BlobValidationResult> {
448
+ try {
449
+ const filePath = await this.resolveFilePath(reference);
450
+
451
+ if (!filePath) {
452
+ return { success: false, error: 'File not found', code: 'FILE_NOT_FOUND' };
453
+ }
454
+
455
+ const stats = await fsPromises.stat(filePath);
456
+ const actual = {
457
+ contentType: await detectContentType(filePath, reference),
458
+ fileSize: stats.size,
459
+ };
460
+
461
+ const validationError = await this.checkUploadedFileMetadata(reference, actual, options);
462
+ if (validationError) return validationError;
463
+
464
+ const result: BlobValidationSuccess = {
465
+ success: true,
466
+ reference,
467
+ viewUrl: this.generateFileUrl(filePath),
468
+ actualFileSize: actual.fileSize,
469
+ };
470
+ if (actual.contentType) result.actualContentType = actual.contentType;
471
+ return result;
472
+ } catch (error) {
473
+ return {
474
+ success: false,
475
+ error: error instanceof Error ? error.message : 'Failed to validate upload',
476
+ code: 'PROVIDER_ERROR',
477
+ };
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Deletes a file from local storage.
483
+ *
484
+ * Security: decodeFileName() rejects traversal/encoding attacks.
485
+ * Containment check ensures resolved path stays within basePath.
486
+ * Symlinks and non-files are rejected.
487
+ */
488
+ async delete(reference: string): Promise<DeleteResult> {
489
+ try {
490
+ const decoded = this.decodeFileName(reference);
491
+ const baseDir = this.basePath;
492
+ const resolvedPath = path.resolve(path.join(baseDir, decoded));
493
+
494
+ if (!resolvedPath.startsWith(baseDir + path.sep) && resolvedPath !== baseDir) {
495
+ return { success: false, reference, error: 'Invalid reference: path is outside storage directory', code: 'PATH_TRAVERSAL' };
496
+ }
497
+
498
+ let stat: fs.Stats;
499
+ try {
500
+ stat = await fsPromises.lstat(resolvedPath);
501
+ } catch {
502
+ return { success: false, reference, error: 'File not found', code: 'FILE_NOT_FOUND' };
503
+ }
504
+
505
+ if (stat.isSymbolicLink()) {
506
+ return { success: false, reference, error: 'Symbolic links cannot be deleted', code: 'VALIDATION_FAILED' };
507
+ }
508
+
509
+ if (!stat.isFile()) {
510
+ return { success: false, reference, error: 'Path is not a regular file', code: 'VALIDATION_FAILED' };
511
+ }
512
+
513
+ await fsPromises.unlink(resolvedPath);
514
+ return { success: true, reference };
515
+ } catch (error) {
516
+ return { success: false, reference, error: error instanceof Error ? error.message : 'Failed to delete file', code: 'PROVIDER_ERROR' };
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Resolves a decoded reference to a verified file path within basePath.
522
+ * Checks containment (path stays inside basePath), rejects symlinks,
523
+ * and verifies the target is a regular file.
524
+ *
525
+ * Callers are responsible for decoding/validating the reference first
526
+ * (via decodeFileName or StorageManager's hasPathTraversal check).
527
+ */
528
+ private async resolveFilePath(reference: string): Promise<string | null> {
529
+ const baseDir = this.basePath;
530
+
531
+ let decoded: string;
532
+ try {
533
+ decoded = this.decodeFileName(reference);
534
+ } catch {
535
+ return null;
536
+ }
537
+
538
+ const directPath = path.join(baseDir, decoded);
539
+ const resolvedPath = path.resolve(directPath);
540
+
541
+ if (!resolvedPath.startsWith(baseDir + path.sep) && resolvedPath !== baseDir) {
542
+ return null;
543
+ }
544
+
545
+ try {
546
+ const stat = await fsPromises.lstat(directPath);
547
+ if (stat.isSymbolicLink() || !stat.isFile()) return null;
548
+ return directPath;
549
+ } catch {
550
+ return null;
551
+ }
552
+ }
553
+
554
+ /**
555
+ * Returns metadata about a file without downloading it.
556
+ * Uses magic byte detection for accurate content type identification.
557
+ */
558
+ async getMetadata(reference: string): Promise<FileInfo | null> {
559
+ const filePath = await this.resolveFilePath(reference);
560
+ if (!filePath) return null;
561
+
562
+ try {
563
+ const stats = await fsPromises.stat(filePath);
564
+ const contentType = await detectContentType(filePath, reference);
565
+
566
+ const info: FileInfo = {
567
+ name: reference,
568
+ size: stats.size,
569
+ lastModified: stats.mtime,
570
+ };
571
+ if (contentType) {
572
+ info.contentType = contentType;
573
+ }
574
+ return info;
575
+ } catch {
576
+ return null;
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Lists files in local storage with optional prefix filtering and pagination.
582
+ */
583
+ async listFiles(
584
+ prefix?: string,
585
+ maxResults: number = 1000,
586
+ continuationToken?: string
587
+ ): Promise<ListFilesResult> {
588
+ try {
589
+ let decodedPrefix: string | undefined;
590
+ if (prefix) {
591
+ try {
592
+ decodedPrefix = decodeURIComponent(prefix);
593
+ } catch {
594
+ return { success: false, error: 'Invalid prefix: malformed URL encoding', code: 'INVALID_INPUT' };
595
+ }
596
+ }
597
+
598
+ if (decodedPrefix && (decodedPrefix.includes('..') || decodedPrefix.includes('\0'))) {
599
+ return { success: false, error: 'Invalid prefix: path traversal sequences are not allowed', code: 'PATH_TRAVERSAL' };
600
+ }
601
+
602
+ const validatedMaxResults = this.validateMaxResults(maxResults);
603
+ const baseDir = this.basePath;
604
+
605
+ try {
606
+ await fsPromises.access(baseDir);
607
+ } catch {
608
+ return { success: true, files: [] };
609
+ }
610
+
611
+ const { files, hasMore } = await walkDirectory(baseDir, {
612
+ prefix: decodedPrefix,
613
+ continuationToken,
614
+ maxCollect: validatedMaxResults + 1,
615
+ });
616
+
617
+ return paginateFiles(files, validatedMaxResults, hasMore);
618
+ } catch (error) {
619
+ return {
620
+ success: false,
621
+ error: error instanceof Error ? error.message : 'Failed to list files',
622
+ code: 'PROVIDER_ERROR',
623
+ };
624
+ }
625
+ }
626
+ }