express-storage 1.0.0 → 1.1.2
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/README.md +519 -348
- package/dist/drivers/azure.driver.d.ts +88 -0
- package/dist/drivers/azure.driver.d.ts.map +1 -0
- package/dist/drivers/azure.driver.js +367 -0
- package/dist/drivers/azure.driver.js.map +1 -0
- package/dist/drivers/base.driver.d.ts +125 -24
- package/dist/drivers/base.driver.d.ts.map +1 -1
- package/dist/drivers/base.driver.js +248 -62
- package/dist/drivers/base.driver.js.map +1 -1
- package/dist/drivers/gcs.driver.d.ts +60 -13
- package/dist/drivers/gcs.driver.d.ts.map +1 -1
- package/dist/drivers/gcs.driver.js +242 -41
- package/dist/drivers/gcs.driver.js.map +1 -1
- package/dist/drivers/local.driver.d.ts +89 -12
- package/dist/drivers/local.driver.d.ts.map +1 -1
- package/dist/drivers/local.driver.js +533 -45
- package/dist/drivers/local.driver.js.map +1 -1
- package/dist/drivers/s3.driver.d.ts +64 -13
- package/dist/drivers/s3.driver.d.ts.map +1 -1
- package/dist/drivers/s3.driver.js +269 -41
- package/dist/drivers/s3.driver.js.map +1 -1
- package/dist/factory/driver.factory.d.ts +35 -29
- package/dist/factory/driver.factory.d.ts.map +1 -1
- package/dist/factory/driver.factory.js +119 -59
- package/dist/factory/driver.factory.js.map +1 -1
- package/dist/index.d.ts +23 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -46
- package/dist/index.js.map +1 -1
- package/dist/storage-manager.d.ts +205 -52
- package/dist/storage-manager.d.ts.map +1 -1
- package/dist/storage-manager.js +644 -73
- package/dist/storage-manager.js.map +1 -1
- package/dist/types/storage.types.d.ts +243 -18
- package/dist/types/storage.types.d.ts.map +1 -1
- package/dist/utils/config.utils.d.ts +28 -4
- package/dist/utils/config.utils.d.ts.map +1 -1
- package/dist/utils/config.utils.js +121 -47
- package/dist/utils/config.utils.js.map +1 -1
- package/dist/utils/file.utils.d.ts +111 -14
- package/dist/utils/file.utils.d.ts.map +1 -1
- package/dist/utils/file.utils.js +215 -32
- package/dist/utils/file.utils.js.map +1 -1
- package/package.json +51 -27
- package/dist/drivers/oci.driver.d.ts +0 -37
- package/dist/drivers/oci.driver.d.ts.map +0 -1
- package/dist/drivers/oci.driver.js +0 -84
- package/dist/drivers/oci.driver.js.map +0 -1
|
@@ -1,9 +1,153 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { BaseStorageDriver } from './base.driver.js';
|
|
4
|
-
import { createMonthBasedPath, ensureDirectoryExists
|
|
4
|
+
import { createMonthBasedPath, ensureDirectoryExists } from '../utils/file.utils.js';
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Magic byte signatures for common file types.
|
|
7
|
+
* Used to detect actual file content type regardless of extension.
|
|
8
|
+
* This provides security against extension spoofing attacks.
|
|
9
|
+
*/
|
|
10
|
+
const MAGIC_BYTES = [
|
|
11
|
+
// Images
|
|
12
|
+
{ bytes: [0xFF, 0xD8, 0xFF], mimeType: 'image/jpeg' },
|
|
13
|
+
{ bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], mimeType: 'image/png' },
|
|
14
|
+
{ bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], mimeType: 'image/gif' }, // GIF87a
|
|
15
|
+
{ bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], mimeType: 'image/gif' }, // GIF89a
|
|
16
|
+
{ bytes: [0x52, 0x49, 0x46, 0x46], mimeType: 'image/webp' }, // RIFF (WebP container)
|
|
17
|
+
{ bytes: [0x42, 0x4D], mimeType: 'image/bmp' },
|
|
18
|
+
// Documents
|
|
19
|
+
{ bytes: [0x25, 0x50, 0x44, 0x46], mimeType: 'application/pdf' }, // %PDF
|
|
20
|
+
{ bytes: [0x50, 0x4B, 0x03, 0x04], mimeType: 'application/zip' }, // ZIP (also docx, xlsx, etc.)
|
|
21
|
+
{ bytes: [0x50, 0x4B, 0x05, 0x06], mimeType: 'application/zip' }, // Empty ZIP
|
|
22
|
+
{ bytes: [0x50, 0x4B, 0x07, 0x08], mimeType: 'application/zip' }, // Spanned ZIP
|
|
23
|
+
// Archives
|
|
24
|
+
{ bytes: [0x1F, 0x8B], mimeType: 'application/gzip' },
|
|
25
|
+
{ bytes: [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], mimeType: 'application/vnd.rar' }, // RAR
|
|
26
|
+
{ bytes: [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], mimeType: 'application/x-7z-compressed' }, // 7z
|
|
27
|
+
// Audio/Video
|
|
28
|
+
{ bytes: [0x49, 0x44, 0x33], mimeType: 'audio/mpeg' }, // ID3 (MP3)
|
|
29
|
+
{ bytes: [0xFF, 0xFB], mimeType: 'audio/mpeg' }, // MP3 without ID3
|
|
30
|
+
{ bytes: [0xFF, 0xFA], mimeType: 'audio/mpeg' }, // MP3 without ID3
|
|
31
|
+
{ bytes: [0x4F, 0x67, 0x67, 0x53], mimeType: 'audio/ogg' }, // OGG
|
|
32
|
+
{ bytes: [0x66, 0x74, 0x79, 0x70], mimeType: 'video/mp4', offset: 4 }, // ftyp (MP4/M4A)
|
|
33
|
+
// Executables (for security detection)
|
|
34
|
+
{ bytes: [0x4D, 0x5A], mimeType: 'application/x-msdownload' }, // EXE/DLL
|
|
35
|
+
{ bytes: [0x7F, 0x45, 0x4C, 0x46], mimeType: 'application/x-executable' }, // ELF
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Detects MIME type from file content using magic bytes.
|
|
39
|
+
* Returns undefined if no match is found (falls back to extension-based detection).
|
|
40
|
+
*
|
|
41
|
+
* @param filePath - Path to the file to analyze
|
|
42
|
+
* @returns Detected MIME type or undefined
|
|
43
|
+
*/
|
|
44
|
+
function detectMimeTypeFromMagicBytes(filePath) {
|
|
45
|
+
try {
|
|
46
|
+
// Read first 16 bytes (enough for most magic numbers)
|
|
47
|
+
const fd = fs.openSync(filePath, 'r');
|
|
48
|
+
const buffer = Buffer.alloc(16);
|
|
49
|
+
const bytesRead = fs.readSync(fd, buffer, 0, 16, 0);
|
|
50
|
+
fs.closeSync(fd);
|
|
51
|
+
if (bytesRead === 0) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
for (const signature of MAGIC_BYTES) {
|
|
55
|
+
const offset = signature.offset || 0;
|
|
56
|
+
if (offset + signature.bytes.length > bytesRead) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
let matches = true;
|
|
60
|
+
for (let i = 0; i < signature.bytes.length; i++) {
|
|
61
|
+
if (buffer[offset + i] !== signature.bytes[i]) {
|
|
62
|
+
matches = false;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (matches) {
|
|
67
|
+
return signature.mimeType;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Maps file extensions to MIME types.
|
|
78
|
+
* Used as fallback when magic byte detection doesn't match.
|
|
79
|
+
*/
|
|
80
|
+
const EXTENSION_MIME_MAP = {
|
|
81
|
+
// Images
|
|
82
|
+
'.jpg': 'image/jpeg',
|
|
83
|
+
'.jpeg': 'image/jpeg',
|
|
84
|
+
'.png': 'image/png',
|
|
85
|
+
'.gif': 'image/gif',
|
|
86
|
+
'.webp': 'image/webp',
|
|
87
|
+
'.svg': 'image/svg+xml',
|
|
88
|
+
'.ico': 'image/x-icon',
|
|
89
|
+
'.bmp': 'image/bmp',
|
|
90
|
+
'.tiff': 'image/tiff',
|
|
91
|
+
'.tif': 'image/tiff',
|
|
92
|
+
// Documents
|
|
93
|
+
'.pdf': 'application/pdf',
|
|
94
|
+
'.doc': 'application/msword',
|
|
95
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
96
|
+
'.xls': 'application/vnd.ms-excel',
|
|
97
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
98
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
99
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
100
|
+
'.txt': 'text/plain',
|
|
101
|
+
'.csv': 'text/csv',
|
|
102
|
+
'.json': 'application/json',
|
|
103
|
+
'.xml': 'application/xml',
|
|
104
|
+
// Audio
|
|
105
|
+
'.mp3': 'audio/mpeg',
|
|
106
|
+
'.wav': 'audio/wav',
|
|
107
|
+
'.ogg': 'audio/ogg',
|
|
108
|
+
'.m4a': 'audio/mp4',
|
|
109
|
+
// Video
|
|
110
|
+
'.mp4': 'video/mp4',
|
|
111
|
+
'.webm': 'video/webm',
|
|
112
|
+
'.avi': 'video/x-msvideo',
|
|
113
|
+
'.mov': 'video/quicktime',
|
|
114
|
+
'.mkv': 'video/x-matroska',
|
|
115
|
+
// Archives
|
|
116
|
+
'.zip': 'application/zip',
|
|
117
|
+
'.tar': 'application/x-tar',
|
|
118
|
+
'.gz': 'application/gzip',
|
|
119
|
+
'.rar': 'application/vnd.rar',
|
|
120
|
+
'.7z': 'application/x-7z-compressed',
|
|
121
|
+
// Web
|
|
122
|
+
'.html': 'text/html',
|
|
123
|
+
'.htm': 'text/html',
|
|
124
|
+
'.css': 'text/css',
|
|
125
|
+
'.js': 'application/javascript',
|
|
126
|
+
'.ts': 'application/typescript',
|
|
127
|
+
// Fonts
|
|
128
|
+
'.woff': 'font/woff',
|
|
129
|
+
'.woff2': 'font/woff2',
|
|
130
|
+
'.ttf': 'font/ttf',
|
|
131
|
+
'.otf': 'font/otf',
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* LocalStorageDriver - Saves files to your local filesystem.
|
|
135
|
+
*
|
|
136
|
+
* Great for development and small-scale applications.
|
|
137
|
+
* Files are organized by year/month folders automatically.
|
|
138
|
+
*
|
|
139
|
+
* **Security features:**
|
|
140
|
+
* - Path traversal prevention (blocks ../ and null bytes)
|
|
141
|
+
* - Symlinks are NOT followed or deleted (prevents directory escape attacks)
|
|
142
|
+
* - Magic byte detection for content-type validation (prevents extension spoofing)
|
|
143
|
+
* - Files stay within the configured base directory
|
|
144
|
+
*
|
|
145
|
+
* **Symlink behavior:** This driver explicitly rejects symlinks for security.
|
|
146
|
+
* If a file is a symlink, it will not be read, deleted, or listed. This prevents
|
|
147
|
+
* attackers from using symlinks to access files outside the storage directory.
|
|
148
|
+
*
|
|
149
|
+
* Note: Local storage doesn't support presigned URLs since
|
|
150
|
+
* there's no external service to sign requests against.
|
|
7
151
|
*/
|
|
8
152
|
export class LocalStorageDriver extends BaseStorageDriver {
|
|
9
153
|
constructor(config) {
|
|
@@ -11,92 +155,436 @@ export class LocalStorageDriver extends BaseStorageDriver {
|
|
|
11
155
|
this.basePath = config.localPath || 'public/express-storage';
|
|
12
156
|
}
|
|
13
157
|
/**
|
|
14
|
-
*
|
|
158
|
+
* Saves a file to the local filesystem.
|
|
159
|
+
*
|
|
160
|
+
* Files are automatically organized into YYYY/MM folders.
|
|
161
|
+
* For example, a file uploaded in January 2026 goes into:
|
|
162
|
+
* {basePath}/2026/01/{unique_filename}.jpg
|
|
163
|
+
*
|
|
164
|
+
* For large files (>100MB), uses streaming to reduce memory usage
|
|
165
|
+
* and prevent application crashes.
|
|
15
166
|
*/
|
|
16
|
-
async upload(file) {
|
|
167
|
+
async upload(file, _options) {
|
|
17
168
|
try {
|
|
18
|
-
// Validate file
|
|
19
169
|
const validationErrors = this.validateFile(file);
|
|
20
170
|
if (validationErrors.length > 0) {
|
|
21
171
|
return this.createErrorResult(validationErrors.join(', '));
|
|
22
172
|
}
|
|
23
|
-
// Generate unique filename
|
|
24
173
|
const fileName = this.generateFileName(file.originalname);
|
|
25
|
-
// Create month-based directory path
|
|
26
174
|
const monthPath = createMonthBasedPath(this.basePath);
|
|
27
175
|
const fullDirPath = path.resolve(monthPath);
|
|
28
|
-
// Ensure directory exists
|
|
29
176
|
ensureDirectoryExists(fullDirPath);
|
|
30
|
-
// Create full file path
|
|
31
177
|
const filePath = path.join(fullDirPath, fileName);
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
178
|
+
// Use streaming for large files to reduce memory usage
|
|
179
|
+
if (this.shouldUseStreaming(file)) {
|
|
180
|
+
await this.uploadWithStream(file, filePath);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
const fileContent = this.getFileContent(file);
|
|
184
|
+
fs.writeFileSync(filePath, fileContent);
|
|
185
|
+
}
|
|
186
|
+
const fileUrl = this.generateFileUrl(filePath);
|
|
187
|
+
// Return relative path from basePath (e.g., '2026/01/filename.jpg')
|
|
188
|
+
const absoluteFilePath = path.resolve(filePath);
|
|
189
|
+
const absoluteBasePath = path.resolve(this.basePath);
|
|
190
|
+
const relativePath = this.normalizePathSeparators(path.relative(absoluteBasePath, absoluteFilePath));
|
|
191
|
+
return this.createSuccessResult(relativePath, fileUrl);
|
|
38
192
|
}
|
|
39
193
|
catch (error) {
|
|
40
194
|
return this.createErrorResult(error instanceof Error ? error.message : 'Failed to upload file');
|
|
41
195
|
}
|
|
42
196
|
}
|
|
43
197
|
/**
|
|
44
|
-
*
|
|
198
|
+
* Uploads a large file using streaming.
|
|
199
|
+
*
|
|
200
|
+
* This method pipes the file stream directly to disk, which is more
|
|
201
|
+
* memory-efficient for large files (>100MB).
|
|
202
|
+
*/
|
|
203
|
+
async uploadWithStream(file, filePath) {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const readStream = this.getFileStream(file);
|
|
206
|
+
const writeStream = fs.createWriteStream(filePath);
|
|
207
|
+
readStream
|
|
208
|
+
.pipe(writeStream)
|
|
209
|
+
.on('finish', resolve)
|
|
210
|
+
.on('error', (err) => {
|
|
211
|
+
// Clean up partial file on error
|
|
212
|
+
try {
|
|
213
|
+
if (fs.existsSync(filePath)) {
|
|
214
|
+
fs.unlinkSync(filePath);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Ignore cleanup errors
|
|
219
|
+
}
|
|
220
|
+
reject(err);
|
|
221
|
+
});
|
|
222
|
+
// Handle read stream errors
|
|
223
|
+
readStream.on('error', (err) => {
|
|
224
|
+
writeStream.destroy();
|
|
225
|
+
try {
|
|
226
|
+
if (fs.existsSync(filePath)) {
|
|
227
|
+
fs.unlinkSync(filePath);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// Ignore cleanup errors
|
|
232
|
+
}
|
|
233
|
+
reject(err);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Builds a URL for accessing the file.
|
|
239
|
+
*
|
|
240
|
+
* If your basePath starts with 'public/', we strip that prefix
|
|
241
|
+
* since Express.static('public') serves files from /
|
|
242
|
+
*
|
|
243
|
+
* Example: public/uploads/2026/01/photo.jpg -> /uploads/2026/01/photo.jpg
|
|
244
|
+
*/
|
|
245
|
+
generateFileUrl(filePath) {
|
|
246
|
+
const absoluteFilePath = path.resolve(filePath);
|
|
247
|
+
const absoluteBasePath = path.resolve(this.basePath);
|
|
248
|
+
const relativeFromBase = this.normalizePathSeparators(path.relative(absoluteBasePath, absoluteFilePath));
|
|
249
|
+
const normalizedBasePath = this.normalizePathSeparators(this.basePath);
|
|
250
|
+
if (normalizedBasePath.startsWith('public/')) {
|
|
251
|
+
const webBasePath = normalizedBasePath.replace(/^public\//, '');
|
|
252
|
+
return this.normalizeUrl(`/${webBasePath}/${relativeFromBase}`);
|
|
253
|
+
}
|
|
254
|
+
return this.normalizeUrl(`/${normalizedBasePath}/${relativeFromBase}`);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Converts Windows backslashes to forward slashes.
|
|
258
|
+
*/
|
|
259
|
+
normalizePathSeparators(pathStr) {
|
|
260
|
+
return pathStr.replace(/\\/g, '/');
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Removes duplicate slashes from URLs.
|
|
45
264
|
*/
|
|
46
|
-
|
|
265
|
+
normalizeUrl(url) {
|
|
266
|
+
return url.replace(/\/+/g, '/');
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Local storage doesn't support presigned upload URLs.
|
|
270
|
+
*/
|
|
271
|
+
async generateUploadUrl(_fileName, _contentType, _maxSize) {
|
|
47
272
|
return this.createPresignedErrorResult('Presigned URLs are not supported for local storage');
|
|
48
273
|
}
|
|
49
274
|
/**
|
|
50
|
-
*
|
|
275
|
+
* Local storage doesn't support presigned view URLs.
|
|
51
276
|
*/
|
|
52
277
|
async generateViewUrl(_fileName) {
|
|
53
278
|
return this.createPresignedErrorResult('Presigned URLs are not supported for local storage');
|
|
54
279
|
}
|
|
55
280
|
/**
|
|
56
|
-
*
|
|
281
|
+
* Validates a local file exists and matches expected values.
|
|
282
|
+
*
|
|
283
|
+
* Content type detection uses a two-tier approach:
|
|
284
|
+
* 1. Magic byte detection (examines actual file content for security)
|
|
285
|
+
* 2. Extension-based fallback (when magic bytes don't match)
|
|
286
|
+
*
|
|
287
|
+
* This helps detect extension spoofing attacks where a malicious file
|
|
288
|
+
* is renamed with an innocent extension (e.g., malware.exe -> photo.jpg).
|
|
289
|
+
*/
|
|
290
|
+
async validateAndConfirmUpload(reference, options) {
|
|
291
|
+
const deleteOnFailure = options?.deleteOnFailure !== false;
|
|
292
|
+
try {
|
|
293
|
+
const filePath = this.resolveFilePath(reference);
|
|
294
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
295
|
+
return {
|
|
296
|
+
success: false,
|
|
297
|
+
error: 'File not found',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const stats = fs.statSync(filePath);
|
|
301
|
+
const fileUrl = this.generateFileUrl(filePath);
|
|
302
|
+
// Try magic byte detection first (more secure), fall back to extension
|
|
303
|
+
const magicMimeType = detectMimeTypeFromMagicBytes(filePath);
|
|
304
|
+
const ext = path.extname(reference).toLowerCase();
|
|
305
|
+
const extensionMimeType = ext && EXTENSION_MIME_MAP[ext] ? EXTENSION_MIME_MAP[ext] : undefined;
|
|
306
|
+
// Use magic bytes if detected, otherwise fall back to extension
|
|
307
|
+
const actualContentType = magicMimeType || extensionMimeType;
|
|
308
|
+
const actualFileSize = stats.size;
|
|
309
|
+
// Security check: warn if magic bytes differ from extension (potential spoofing)
|
|
310
|
+
const contentTypeMismatchWarning = magicMimeType && extensionMimeType &&
|
|
311
|
+
magicMimeType !== extensionMimeType;
|
|
312
|
+
// Validate content type if expected
|
|
313
|
+
if (options?.expectedContentType && actualContentType !== options.expectedContentType) {
|
|
314
|
+
if (deleteOnFailure) {
|
|
315
|
+
await this.delete(reference);
|
|
316
|
+
}
|
|
317
|
+
const mismatchDetail = contentTypeMismatchWarning
|
|
318
|
+
? ` (Warning: file extension suggests '${extensionMimeType}' but actual content is '${magicMimeType}')`
|
|
319
|
+
: '';
|
|
320
|
+
const errorResult = {
|
|
321
|
+
success: false,
|
|
322
|
+
error: `Content type mismatch: expected '${options.expectedContentType}', got '${actualContentType || 'unknown'}'${mismatchDetail}${deleteOnFailure ? ' (file deleted)' : ' (file kept for inspection)'}`,
|
|
323
|
+
};
|
|
324
|
+
if (actualContentType)
|
|
325
|
+
errorResult.actualContentType = actualContentType;
|
|
326
|
+
errorResult.actualFileSize = actualFileSize;
|
|
327
|
+
return errorResult;
|
|
328
|
+
}
|
|
329
|
+
// Validate file size if expected
|
|
330
|
+
if (options?.expectedFileSize !== undefined && actualFileSize !== options.expectedFileSize) {
|
|
331
|
+
if (deleteOnFailure) {
|
|
332
|
+
await this.delete(reference);
|
|
333
|
+
}
|
|
334
|
+
const errorResult = {
|
|
335
|
+
success: false,
|
|
336
|
+
error: `File size mismatch: expected ${options.expectedFileSize} bytes, got ${actualFileSize} bytes${deleteOnFailure ? ' (file deleted)' : ' (file kept for inspection)'}`,
|
|
337
|
+
};
|
|
338
|
+
if (actualContentType)
|
|
339
|
+
errorResult.actualContentType = actualContentType;
|
|
340
|
+
errorResult.actualFileSize = actualFileSize;
|
|
341
|
+
return errorResult;
|
|
342
|
+
}
|
|
343
|
+
const result = {
|
|
344
|
+
success: true,
|
|
345
|
+
reference,
|
|
346
|
+
viewUrl: fileUrl,
|
|
347
|
+
actualFileSize,
|
|
348
|
+
};
|
|
349
|
+
if (actualContentType) {
|
|
350
|
+
result.actualContentType = actualContentType;
|
|
351
|
+
}
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
return {
|
|
356
|
+
success: false,
|
|
357
|
+
error: error instanceof Error ? error.message : 'Failed to validate upload',
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Deletes a file from local storage.
|
|
363
|
+
*
|
|
364
|
+
* Security checks:
|
|
365
|
+
* - Rejects path traversal attempts (../ sequences)
|
|
366
|
+
* - Rejects null bytes in paths
|
|
367
|
+
* - Verifies target stays within base directory
|
|
368
|
+
* - Won't follow or delete symlinks (security: prevents directory escape attacks)
|
|
369
|
+
* - Only deletes regular files (not directories)
|
|
370
|
+
*
|
|
371
|
+
* @param reference - The file path relative to the storage base directory
|
|
372
|
+
* @returns true if file was deleted, false if not found or security check failed
|
|
57
373
|
*/
|
|
58
|
-
async delete(
|
|
374
|
+
async delete(reference) {
|
|
59
375
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
376
|
+
if (reference.includes('..') || reference.includes('\0')) {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
const baseDir = path.resolve(this.basePath);
|
|
380
|
+
const targetPath = path.join(baseDir, reference);
|
|
381
|
+
const resolvedPath = path.resolve(targetPath);
|
|
382
|
+
// Make sure we're not escaping the base directory
|
|
383
|
+
if (!resolvedPath.startsWith(baseDir + path.sep) && resolvedPath !== baseDir) {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
let stat;
|
|
387
|
+
try {
|
|
388
|
+
stat = fs.lstatSync(resolvedPath);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
// Don't follow symlinks — security protection against:
|
|
394
|
+
// 1. Directory escape attacks (symlink pointing outside storage)
|
|
395
|
+
// 2. Unauthorized file deletion via symlink redirection
|
|
396
|
+
// 3. Race conditions where symlink is swapped after check
|
|
397
|
+
if (stat.isSymbolicLink()) {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
if (!stat.isFile()) {
|
|
63
401
|
return false;
|
|
64
402
|
}
|
|
65
|
-
|
|
66
|
-
fs.unlinkSync(filePath);
|
|
403
|
+
fs.unlinkSync(resolvedPath);
|
|
67
404
|
return true;
|
|
68
405
|
}
|
|
69
|
-
catch
|
|
406
|
+
catch {
|
|
70
407
|
return false;
|
|
71
408
|
}
|
|
72
409
|
}
|
|
73
410
|
/**
|
|
74
|
-
*
|
|
411
|
+
* Safely resolves a reference to a file path within our base directory.
|
|
412
|
+
* Returns null for any suspicious input.
|
|
75
413
|
*/
|
|
76
|
-
|
|
414
|
+
resolveFilePath(reference) {
|
|
77
415
|
const baseDir = path.resolve(this.basePath);
|
|
78
|
-
if (
|
|
416
|
+
if (reference.includes('..') || reference.includes('\0')) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
const directPath = path.join(baseDir, reference);
|
|
420
|
+
const resolvedPath = path.resolve(directPath);
|
|
421
|
+
if (!resolvedPath.startsWith(baseDir + path.sep) && resolvedPath !== baseDir) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
const stat = fs.lstatSync(directPath);
|
|
426
|
+
if (stat.isSymbolicLink()) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
if (stat.isFile()) {
|
|
430
|
+
return directPath;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
79
434
|
return null;
|
|
80
435
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Lists files in local storage with optional prefix filtering and pagination.
|
|
440
|
+
*
|
|
441
|
+
* Uses early termination to avoid loading all files into memory when possible.
|
|
442
|
+
* Files are collected in sorted order and iteration stops once we have enough
|
|
443
|
+
* results for the requested page.
|
|
444
|
+
*/
|
|
445
|
+
async listFiles(prefix, maxResults = 1000, continuationToken) {
|
|
446
|
+
try {
|
|
447
|
+
if (prefix && (prefix.includes('..') || prefix.includes('\0'))) {
|
|
448
|
+
return {
|
|
449
|
+
success: false,
|
|
450
|
+
error: 'Invalid prefix: path traversal sequences are not allowed',
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
const validatedMaxResults = Math.floor(Math.max(1, Math.min(Number.isNaN(maxResults) ? 1000 : maxResults, 1000)));
|
|
454
|
+
const baseDir = path.resolve(this.basePath);
|
|
455
|
+
if (!fs.existsSync(baseDir)) {
|
|
456
|
+
return { success: true, files: [] };
|
|
457
|
+
}
|
|
458
|
+
const matchingFiles = [];
|
|
459
|
+
let hasMore = false;
|
|
460
|
+
// Maximum recursion depth to prevent stack overflow on deeply nested directories
|
|
461
|
+
const MAX_RECURSION_DEPTH = 100;
|
|
462
|
+
// Maximum files to collect before stopping (for memory protection)
|
|
463
|
+
// We collect a bit more than needed for accurate hasMore detection
|
|
464
|
+
const MAX_COLLECT = validatedMaxResults + 1;
|
|
465
|
+
// Skip directories that can't possibly contain matching files
|
|
466
|
+
const couldContainPrefix = (dirRelativePath, targetPrefix) => {
|
|
467
|
+
if (!targetPrefix)
|
|
468
|
+
return true;
|
|
469
|
+
return targetPrefix.startsWith(dirRelativePath) ||
|
|
470
|
+
dirRelativePath.startsWith(targetPrefix) ||
|
|
471
|
+
dirRelativePath === '';
|
|
472
|
+
};
|
|
473
|
+
// Check if we should skip this file based on continuation token
|
|
474
|
+
const isAfterToken = (filePath, token) => {
|
|
475
|
+
if (!token)
|
|
476
|
+
return true;
|
|
477
|
+
return filePath.localeCompare(token) > 0;
|
|
478
|
+
};
|
|
479
|
+
const collectFiles = (dir, dirRelativePath, depth = 0) => {
|
|
480
|
+
// Return true to continue, false to stop early
|
|
481
|
+
// Prevent stack overflow from extremely deep directory structures
|
|
482
|
+
if (depth > MAX_RECURSION_DEPTH) {
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
// Early termination: we have enough files
|
|
486
|
+
if (matchingFiles.length >= MAX_COLLECT) {
|
|
487
|
+
hasMore = true;
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
if (prefix && !couldContainPrefix(dirRelativePath, prefix)) {
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
let items;
|
|
494
|
+
try {
|
|
495
|
+
items = fs.readdirSync(dir);
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return true;
|
|
92
499
|
}
|
|
93
|
-
|
|
94
|
-
|
|
500
|
+
// Sort items for consistent ordering
|
|
501
|
+
items.sort();
|
|
502
|
+
for (const item of items) {
|
|
503
|
+
// Check if we have enough files
|
|
504
|
+
if (matchingFiles.length >= MAX_COLLECT) {
|
|
505
|
+
hasMore = true;
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
const itemPath = path.join(dir, item);
|
|
509
|
+
const relativePath = dirRelativePath ? `${dirRelativePath}/${item}` : item;
|
|
510
|
+
let stat;
|
|
511
|
+
try {
|
|
512
|
+
stat = fs.lstatSync(itemPath);
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
// Skip symlinks for security reasons:
|
|
518
|
+
// 1. Symlinks could point outside the storage directory (directory escape)
|
|
519
|
+
// 2. Symlinks could create infinite loops in directory traversal
|
|
520
|
+
// 3. Symlinks could expose sensitive files from other locations
|
|
521
|
+
// If you need symlink support, use a different storage strategy
|
|
522
|
+
if (stat.isSymbolicLink()) {
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (stat.isDirectory()) {
|
|
526
|
+
// Skip directories that are lexicographically before our continuation token
|
|
527
|
+
// (they can't contain files we need)
|
|
528
|
+
if (continuationToken && !couldContainPrefix(relativePath, continuationToken.split('/')[0] || '')) {
|
|
529
|
+
// Only skip if this directory is completely before the token
|
|
530
|
+
if (relativePath.localeCompare(continuationToken) < 0 && !continuationToken.startsWith(relativePath + '/')) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const shouldContinue = collectFiles(itemPath, relativePath, depth + 1);
|
|
535
|
+
if (!shouldContinue) {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
else if (stat.isFile()) {
|
|
540
|
+
if (prefix && !relativePath.startsWith(prefix)) {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
// Skip files at or before the continuation token
|
|
544
|
+
if (!isAfterToken(relativePath, continuationToken)) {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const fileInfo = {
|
|
548
|
+
name: relativePath,
|
|
549
|
+
size: stat.size,
|
|
550
|
+
lastModified: stat.mtime,
|
|
551
|
+
};
|
|
552
|
+
const ext = path.extname(relativePath).toLowerCase();
|
|
553
|
+
if (ext && EXTENSION_MIME_MAP[ext]) {
|
|
554
|
+
fileInfo.contentType = EXTENSION_MIME_MAP[ext];
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
fileInfo.contentType = 'application/octet-stream';
|
|
558
|
+
}
|
|
559
|
+
matchingFiles.push(fileInfo);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return true;
|
|
563
|
+
};
|
|
564
|
+
collectFiles(baseDir, '');
|
|
565
|
+
// Sort results (should already be mostly sorted due to directory traversal order)
|
|
566
|
+
matchingFiles.sort((a, b) => a.name.localeCompare(b.name));
|
|
567
|
+
// Take only the requested number of results
|
|
568
|
+
const pageFiles = matchingFiles.slice(0, validatedMaxResults);
|
|
569
|
+
const result = {
|
|
570
|
+
success: true,
|
|
571
|
+
files: pageFiles,
|
|
572
|
+
};
|
|
573
|
+
// Set next token if there are more results
|
|
574
|
+
if (matchingFiles.length > validatedMaxResults || hasMore) {
|
|
575
|
+
const lastFile = pageFiles[pageFiles.length - 1];
|
|
576
|
+
if (lastFile) {
|
|
577
|
+
result.nextToken = lastFile.name;
|
|
95
578
|
}
|
|
96
579
|
}
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
|
|
580
|
+
return result;
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
return {
|
|
584
|
+
success: false,
|
|
585
|
+
error: error instanceof Error ? error.message : 'Failed to list files',
|
|
586
|
+
};
|
|
587
|
+
}
|
|
100
588
|
}
|
|
101
589
|
}
|
|
102
590
|
//# sourceMappingURL=local.driver.js.map
|